From ac60fa5c6c229a1c9ad432d33cff80a2e6efe9e0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 1 Aug 2022 12:50:49 -0500 Subject: [PATCH 01/28] 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 d6ab30fc..669197e9 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 476b0706..dd8fc416 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 2e59dffb..6793daed 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 32183000..ab7fdd08 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 02/28] 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 669197e9..b9ca62eb 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 d00966b9..f0eaa317 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 489b87f8..86e9bc4f 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 00000000..cb01150d --- /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 afad965d..38d45afc 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 0a3e5616..35b7744f 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 49bd7807..885d94a0 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 49fb0806..b959169c 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 911bab8c..6841f5c3 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 81f6fbce..769cdbad 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 a16bd484..b3621d66 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 b77e5a80..474a7609 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 8e529de2..16b8daca 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 4fbcaa8b..7e809eb8 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 6db938c9..89ee271e 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 ce22c699..95164d48 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 03/28] 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 cb01150d..c429bbc9 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 b959169c..ba5eb108 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 16b8daca..b41e16fc 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 04/28] 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 b9ca62eb..a33b82dd 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 dd8fc416..6b0872cb 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 05/28] 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 a33b82dd..f193e31a 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 c58e5e0a..abfcf366 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 514b837c..aa3d7aef 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 b67666a1..d297e960 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 06/28] 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 f193e31a..23608d6a 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 5f345c41..284d7b22 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 a9006f6b..32bcc3be 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 07/28] 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 23608d6a..f1337167 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 d1e248d4..da9dcac4 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 c429bbc9..59366a15 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 38d45afc..c123e5b6 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 b8fe0234..df17d5c6 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 35b7744f..b248501d 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 769cdbad..f989d8f2 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 0724b075..7247d9b1 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 b3621d66..8a191e66 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 aa3d7aef..7fd2b579 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 474a7609..859345d4 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 7e809eb8..b609cacb 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 89ee271e..2213e4e6 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 95164d48..d8481391 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 d297e960..07f795a1 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 08/28] 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 da9dcac4..99995c95 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 b248501d..8c0b1567 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 09/28] 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 f1337167..7b8e95d2 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 6b0872cb..638c1217 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 10/28] 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 7b8e95d2..c282d3ad 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 37ab4af6..4fffce75 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 c123e5b6..362119d1 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 638c1217..d3880229 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 feafddd6..d682709b 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 b41e16fc..a062be4d 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 df27af18..4efe5766 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 11/28] 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 c282d3ad..8f5f8be7 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 362119d1..cdf146a0 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 d3880229..9e54f289 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 12/28] 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 688324d1..472064a1 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 d39ed40f..69f0e638 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 fd50ef5c..ea8a73ff 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 64d8f2b5..14e57479 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 9904dcbd..9413bdf0 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 13/28] 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 8f5f8be7..944af832 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 9e54f289..6cc7415d 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 6793daed..d635d178 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 be337235..b0f87a25 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 ab7fdd08..203b3b1f 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 b609cacb..d5bec08f 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 14/28] 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 944af832..6785f321 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 58a0a0d0..22b1db08 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 6cc7415d..67f76446 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 ba5eb108..03c637bc 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 2213e4e6..c24371fc 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 15/28] correct changelog date (#400) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6785f321..87f8fb8b 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 16/28] 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 87f8fb8b..ee6f06fa 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 17/28] 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 ee6f06fa..bcba330d 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 67f76446..9e1406d5 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 03c637bc..0a0a2b77 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 18/28] 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 bcba330d..0ac452e4 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 cdf146a0..04663e40 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 9e1406d5..032e9cb4 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 19/28] 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 472064a1..a5b3e18c 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 0ac452e4..861005dc 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 a3bb56d6..f265ba14 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 4ebf3509..79c54b17 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 a1c3ac5e..00000000 --- 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 4fd75700..00000000 --- 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 86e9bc4f..df4ddc01 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 ce96c547..00000000 --- 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 032e9cb4..30244104 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 5a683f45..00000000 --- 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 ddc039d4..00000000 --- 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 da538e93..00000000 --- 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 a5153d1b..00000000 --- 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 4fa7a29b..e87da7d8 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 5427c653..9be3d29f 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 3dd9fea5..c60fdbf3 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 e69de29b..00000000 diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py deleted file mode 100644 index c07f3c40..00000000 --- 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 d682709b..00000000 --- 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 136e9239..00000000 --- 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 f3dca5be..00000000 --- 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 26aeb550..00000000 --- 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 20/28] 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 a5b3e18c..d9ec99b8 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 4afff063..26262813 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 ea8a73ff..e108ca6f 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 dfc4dfcb..ce003f92 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 14e57479..c2871db6 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 22b1db08..f104e210 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 04663e40..72965d45 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 b1948da9..640f67b9 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 9413bdf0..e8cea7f8 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 21/28] 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 861005dc..d5369176 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 30244104..6cea18d8 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 b0f87a25..114fb706 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 22/28] 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 e108ca6f..2aa02626 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 23/28] update readthedocs build config (#411) --- .readthedocs.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index cc1c33d8..186d4e54 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 24/28] 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 d9ec99b8..a8dc027d 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 2aa02626..38ace65f 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 64645e65..55779389 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 d5369176..e1f36d6f 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 5527425d..6ff27506 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 94ced009..87a5ab36 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 72965d45..694c4a7e 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 9be3d29f..519d2d21 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 114fb706..2b2ab51f 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 4c98f95c..d8295dac 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 e8cea7f8..b69f3de9 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 25/28] 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 e1f36d6f..d7f686e6 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 6cea18d8..4a7bff54 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 26/28] 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 26262813..89444967 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 ce003f92..383e3196 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 d7f686e6..bf77a00e 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 d8f2507c..cb0d9050 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 4c39ea8b..96c7eb82 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 29eb0e46..d2d70f43 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 ac124f0a..b2e5665a 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 f0eaa317..15c37a73 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 67a11408..ff218d34 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 1b48ba24..b52b462b 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 df4ddc01..bbf07f09 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 bc646262..00000000 --- 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 06f72e05..00000000 --- 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 4cfdbfa8..00000000 --- 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 a40daa6f..00000000 --- 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 59366a15..00000000 --- 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 b269a196..00000000 --- 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 0e034eed..294bdf61 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 6d90ac03..314e9d76 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 68f843cd..0671cde7 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 99e518af..199cb7d1 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 0a0a2b77..eae94d3f 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 342772d2..95f3c477 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 f4b0c7b6..4c6835e9 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 8567fa1e..8faf50ae 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 27/28] 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 bf77a00e..f24b5e5e 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 51465879..c2899507 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 694c4a7e..06878080 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 4a7bff54..d84d79d4 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 28/28] 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 f265ba14..c0c485cb 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 06878080..1fd44326 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 4031de51..07c8badf 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"