diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e577953..3f76775a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The intended audience of this file is for `incydr` SDK and CLI consumers -- as such, changes that don't affect how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- The `files` client to the SDK with two methods: + - `sdk.files.v1.download_file_by_sha256` to download a file and save it in the file system. + - `sdk.files.v1.stream_file_by_sha256` to stream a file, allowing more control over how it is downloaded. +- Added the `files download` command to the CLI to download a file by SHA256 hash. ## 2.3.1 - 2025-05-13 diff --git a/docs/cli/cmds/files.md b/docs/cli/cmds/files.md new file mode 100644 index 00000000..efe83f2a --- /dev/null +++ b/docs/cli/cmds/files.md @@ -0,0 +1,6 @@ +# Files Commands + +::: mkdocs-click + :module: _incydr_cli.cmds.files + :command: files + :list_subcommands: diff --git a/docs/sdk/clients/files.md b/docs/sdk/clients/files.md new file mode 100644 index 00000000..282b538a --- /dev/null +++ b/docs/sdk/clients/files.md @@ -0,0 +1,5 @@ +# Files + +::: _incydr_sdk.files.client.FilesV1 + :docstring: + :members: diff --git a/mkdocs.yml b/mkdocs.yml index 0ebe68ee..ce82a084 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - Departments: 'sdk/clients/departments.md' - Directory Groups: 'sdk/clients/directory_groups.md' - File Events: 'sdk/clients/file_events.md' + - Files: 'sdk/clients/files.md' - File Event Querying: 'sdk/clients/file_event_queries.md' - Sessions: 'sdk/clients/sessions.md' - Trusted Activites: 'sdk/clients/trusted_activities.md' @@ -78,6 +79,7 @@ nav: - Departments: 'cli/cmds/departments.md' - Directory Groups: 'cli/cmds/directory_groups.md' - File Events: 'cli/cmds/file_events.md' + - Files: 'cli/cmds/files.md' - Sessions: 'cli/cmds/sessions.md' - Trusted Activites: 'cli/cmds/trusted_activities.md' - Users: 'cli/cmds/users.md' diff --git a/src/_incydr_cli/cmds/files.py b/src/_incydr_cli/cmds/files.py new file mode 100644 index 00000000..49da71b8 --- /dev/null +++ b/src/_incydr_cli/cmds/files.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path + +import click + +from _incydr_cli import logging_options +from _incydr_cli.core import IncydrCommand +from _incydr_cli.core import IncydrGroup +from _incydr_sdk.core.client import Client + +path_option = click.option( + "--path", + help='The file path where to save the file. The path must include the file name (e.g. "/path/to/my_file.txt"). Defaults to a file named "downloaded_file" in the current directory.', + default=str(Path(os.getcwd()) / "downloaded_file"), +) + + +@click.group(cls=IncydrGroup) +@logging_options +def files(): + """Download files by SHA256 hash.""" + + +@files.command(cls=IncydrCommand) +@click.argument("SHA256") +@path_option +@logging_options +def download(sha256: str, path: str): + """ + Download the file matching the given SHA256 hash to the target path. + """ + client = Client() + client.files.v1.download_file_by_sha256(sha256, path) diff --git a/src/_incydr_cli/main.py b/src/_incydr_cli/main.py index 74cc2a0e..8b28ae03 100644 --- a/src/_incydr_cli/main.py +++ b/src/_incydr_cli/main.py @@ -18,6 +18,7 @@ from _incydr_cli.cmds.devices import devices from _incydr_cli.cmds.directory_groups import directory_groups from _incydr_cli.cmds.file_events import file_events +from _incydr_cli.cmds.files import files as files_client from _incydr_cli.cmds.risk_profiles import risk_profiles from _incydr_cli.cmds.sessions import sessions from _incydr_cli.cmds.trusted_activities import trusted_activities @@ -81,6 +82,7 @@ def incydr(version, python, script_dir): incydr.add_command(devices) incydr.add_command(directory_groups) incydr.add_command(file_events) +incydr.add_command(files_client) incydr.add_command(cases) incydr.add_command(risk_profiles) incydr.add_command(sessions) diff --git a/src/_incydr_sdk/core/client.py b/src/_incydr_sdk/core/client.py index 9d598ca3..057006d6 100644 --- a/src/_incydr_sdk/core/client.py +++ b/src/_incydr_sdk/core/client.py @@ -22,6 +22,7 @@ from _incydr_sdk.directory_groups.client import DirectoryGroupsClient from _incydr_sdk.exceptions import AuthMissingError from _incydr_sdk.file_events.client import FileEventsClient +from _incydr_sdk.files.client import FilesClient from _incydr_sdk.risk_profiles.client import RiskProfiles from _incydr_sdk.sessions.client import SessionsClient from _incydr_sdk.trusted_activities.client import TrustedActivitiesClient @@ -106,6 +107,7 @@ def response_hook(response, *args, **kwargs): self._devices = DevicesClient(self) self._directory_groups = DirectoryGroupsClient(self) self._file_events = FileEventsClient(self) + self._files = FilesClient(self) self._sessions = SessionsClient(self) self._trusted_activities = TrustedActivitiesClient(self) self._users = UsersClient(self) @@ -283,6 +285,16 @@ def file_events(self): """ return self._file_events + @property + def files(self): + """ + Property returning a [`FilesClient`](../files) for interacting with `/v1/files` API endpoints. + Usage: + + >>> client.files.v1.get_file_by_sha256("sha256 hash", "/path/to/file.extension") + """ + return self._files + @property def sessions(self): """ diff --git a/src/_incydr_sdk/core/settings.py b/src/_incydr_sdk/core/settings.py index 2ee16c82..80af344e 100644 --- a/src/_incydr_sdk/core/settings.py +++ b/src/_incydr_sdk/core/settings.py @@ -225,8 +225,12 @@ def _log_response_debug(self, response): dumped = indent(dumped, prefix="\t") self.logger.debug(dumped) except Exception as err: - self.logger.debug(f"Error dumping request/response info: {err}") - self.logger.debug(response) + if isinstance(response.content, bytes): + self.logger.debug("Unable to log binary response data.") + self.logger.debug(response) + else: + self.logger.debug(f"Error dumping request/response info: {err}") + self.logger.debug(response) def _log_error(self, err, invocation_str=None): message = str(err) if err else None diff --git a/src/_incydr_sdk/files/client.py b/src/_incydr_sdk/files/client.py new file mode 100644 index 00000000..a1f83924 --- /dev/null +++ b/src/_incydr_sdk/files/client.py @@ -0,0 +1,67 @@ +from pathlib import Path + + +class FilesClient: + def __init__(self, parent): + self._parent = parent + self._v1 = None + + @property + def v1(self): + if self._v1 is None: + self._v1 = FilesV1(self._parent) + return self._v1 + + +class FilesV1: + """Client for `/v1/files` endpoints. + + Usage example: + + >>> import incydr + >>> + >>> client = incydr.Client(**kwargs) + >>> client.files.v1.download_file_by_sha256("example_hash", "./testfile.test") + """ + + def __init__(self, parent): + self._parent = parent + + def download_file_by_sha256(self, sha256: str, target_path: Path) -> Path: + """Download a file that matches the given SHA256 hash. + + **Parameters:** + + * **sh256**: `str` (required) The SHA256 hash matching the file you wish to download. + * **target_path**: `Path | str` a string or `pathlib.Path` object that represents the target file path and + name to which the file will be saved to. + + **Returns**: A `pathlib.Path` object representing the location of the downloaded file. + """ + target = Path( + target_path + ) # ensure that target is a path even if we're given a string + response = self._parent.session.get(f"/v1/files/get-file-by-sha256/{sha256}") + target.write_bytes(response.content) + return target + + def stream_file_by_sha256(self, sha256: str): + """Stream a file that matches the given SHA256 hash. + + **Example usage:** + ``` + >>> with sdk.files.v1.stream_file_by_sha256("example_hash") as response: + >>> with open("./testfile.zip", "wb") as file: + >>> for chunk in response.iter_content(chunk_size=128): + >>> file.write(chunk) + ``` + + **Parameters:** + + * **sh256**: `str` (required) The SHA256 hash matching the file you wish to download. + + **Returns**: A `requests.Response` object with a stream of the requested file. + """ + return self._parent.session.get( + f"/v1/files/get-file-by-sha256/{sha256}", stream=True + ) diff --git a/tests/test_agents.py b/tests/test_agents.py index 3565a21c..7aebe47f 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -439,7 +439,7 @@ def test_cli_bulk_deactivate_JSON_file_input(httpserver_auth: HTTPServer, runner def test_cli_bulk_activate_retries_with_agent_ids_not_found_removed( httpserver_auth: HTTPServer, runner ): - input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876")) + input_lines = ("agent_id", "1234", "5678", "2345", "9876") httpserver_auth.expect_request( uri="/v1/agents/activate", @@ -453,21 +453,25 @@ def test_cli_bulk_activate_retries_with_agent_ids_not_found_removed( httpserver_auth.expect_request( uri="/v1/agents/activate", method="POST", json={"agentIds": ["2345", "1234"]} ).respond_with_data(status=204) - - result = runner.invoke( - incydr, ["agents", "bulk-activate", "--format", "csv", "-"], input=input_lines - ) - assert ( - "404 Error processing batch of 4 agent activations, agent_ids not found: ['5678', '9876']" - in result.output - ) - assert "Activating agents..." in result.output + with runner.isolated_filesystem(): + with open("tmpfile", "w") as tmpfile: + for line in input_lines: + tmpfile.write(line) + tmpfile.write("\n") + result = runner.invoke( + incydr, ["agents", "bulk-activate", "--format", "csv", "tmpfile"] + ) + assert ( + "404 Error processing batch of 4 agent activations, agent_ids not found: ['5678', '9876']" + in result.output + ) + assert "Activating agents..." in result.output def test_cli_bulk_activate_retries_ids_individually_when_unknown_error_occurs( httpserver_auth: HTTPServer, runner ): - input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876")) + input_lines = ("agent_id", "1234", "5678", "2345", "9876") httpserver_auth.expect_request( uri="/v1/agents/activate", @@ -487,21 +491,28 @@ def test_cli_bulk_activate_retries_ids_individually_when_unknown_error_occurs( uri="/v1/agents/activate", method="POST", json={"agentIds": ["9876"]} ).respond_with_data(status=204) - result = runner.invoke( - incydr, ["agents", "bulk-activate", "--format", "csv", "-"], input=input_lines - ) - assert "Unknown error processing batch of 4 agent activations" in result.output - assert "Trying agent activation for this batch individually" in result.output - assert "Activating agents..." in result.output - assert ( - "Failed to process activation for 5678: Unknown Server Error" in result.output - ) + with runner.isolated_filesystem(): + with open("tmpfile", "w") as tmpfile: + for line in input_lines: + tmpfile.write(line) + tmpfile.write("\n") + + result = runner.invoke( + incydr, ["agents", "bulk-activate", "--format", "csv", "tmpfile"] + ) + assert "Unknown error processing batch of 4 agent activations" in result.output + assert "Trying agent activation for this batch individually" in result.output + assert "Activating agents..." in result.output + assert ( + "Failed to process activation for 5678: Unknown Server Error" + in result.output + ) def test_cli_bulk_deactivate_retries_with_agent_ids_not_found_removed( httpserver_auth: HTTPServer, runner ): - input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876")) + input_lines = ("agent_id", "1234", "5678", "2345", "9876") httpserver_auth.expect_request( uri="/v1/agents/deactivate", @@ -515,21 +526,26 @@ def test_cli_bulk_deactivate_retries_with_agent_ids_not_found_removed( httpserver_auth.expect_request( uri="/v1/agents/deactivate", method="POST", json={"agentIds": ["2345", "1234"]} ).respond_with_data(status=204) + with runner.isolated_filesystem(): + with open("tmpfile", "w") as tmpfile: + for line in input_lines: + tmpfile.write(line) + tmpfile.write("\n") - result = runner.invoke( - incydr, ["agents", "bulk-deactivate", "--format", "csv", "-"], input=input_lines - ) - assert ( - "404 Error processing batch of 4 agent deactivations, agent_ids not found: ['5678', '9876']" - in result.output - ) - assert "Deactivating agents..." in result.output + result = runner.invoke( + incydr, ["agents", "bulk-deactivate", "--format", "csv", "tmpfile"] + ) + assert ( + "404 Error processing batch of 4 agent deactivations, agent_ids not found: ['5678', '9876']" + in result.output + ) + assert "Deactivating agents..." in result.output def test_cli_bulk_deactivate_retries_ids_individually_when_unknown_error_occurs( httpserver_auth: HTTPServer, runner ): - input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876")) + input_lines = ("agent_id", "1234", "5678", "2345", "9876") httpserver_auth.expect_request( uri="/v1/agents/deactivate", @@ -548,13 +564,21 @@ def test_cli_bulk_deactivate_retries_ids_individually_when_unknown_error_occurs( httpserver_auth.expect_request( uri="/v1/agents/deactivate", method="POST", json={"agentIds": ["9876"]} ).respond_with_data(status=204) + with runner.isolated_filesystem(): + with open("tmpfile", "w") as tmpfile: + for line in input_lines: + tmpfile.write(line) + tmpfile.write("\n") - result = runner.invoke( - incydr, ["agents", "bulk-deactivate", "--format", "csv", "-"], input=input_lines - ) - assert "Unknown error processing batch of 4 agent deactivations" in result.output - assert "Trying agent deactivation for this batch individually" in result.output - assert "Deactivating agents..." in result.output - assert ( - "Failed to process deactivation for 5678: Unknown Server Error" in result.output - ) + result = runner.invoke( + incydr, ["agents", "bulk-deactivate", "--format", "csv", "tmpfile"] + ) + assert ( + "Unknown error processing batch of 4 agent deactivations" in result.output + ) + assert "Trying agent deactivation for this batch individually" in result.output + assert "Deactivating agents..." in result.output + assert ( + "Failed to process deactivation for 5678: Unknown Server Error" + in result.output + ) diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 00000000..e2650de7 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,86 @@ +import pytest +from pytest_httpserver import HTTPServer +from requests.exceptions import HTTPError + +from _incydr_cli.main import incydr +from incydr import Client + + +TEST_SHA256 = "38acb15d02d5ac0f2a2789602e9df950c380d2799b4bdb59394e4eeabdd3a662" +BAD_SHA256 = "asdf" +TEST_DATA = b"test data" + + +@pytest.fixture +def mock_file_download(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/files/get-file-by-sha256/{TEST_SHA256}" + ).respond_with_data(response_data=TEST_DATA, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_bad_sha256(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/files/get-file-by-sha256/{BAD_SHA256}" + ).respond_with_data(response_data="", status=400) + return httpserver_auth + + +@pytest.fixture +def mock_file_not_found(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/files/get-file-by-sha256/{TEST_SHA256}" + ).respond_with_data(response_data="", status=404) + return httpserver_auth + + +def test_download_file_by_sha256_calls_with_correct_parameter( + mock_file_download, tmp_path +): + c = Client() + p = tmp_path / "testfile.test" + f = c.files.v1.download_file_by_sha256(TEST_SHA256, p) + with open(f, "rb") as file: + content = file.read() + assert content == TEST_DATA + mock_file_download.check() + + +def test_download_file_by_sha256_raises_error_when_invalid_sha256(mock_bad_sha256): + c = Client() + with pytest.raises(HTTPError) as error: + c.files.v1.download_file_by_sha256(BAD_SHA256, "testpath.text") + assert error.value.response.status_code == 400 + mock_bad_sha256.check() + + +def test_download_file_by_sha256_raises_error_when_file_not_found(mock_file_not_found): + c = Client() + with pytest.raises(HTTPError) as error: + c.files.v1.download_file_by_sha256(TEST_SHA256, "testpath.text") + assert error.value.response.status_code == 404 + mock_file_not_found.check() + + +def test_stream_file_by_sha256_streams_file(mock_file_download): + c = Client() + response = c.files.v1.stream_file_by_sha256(TEST_SHA256) + content = b"".join([c for c in response.iter_content(chunk_size=128) if c]) + assert content == TEST_DATA + + +# ************************************************ CLI ************************************************ + + +def test_cli_download_downloads_file(runner, mock_file_download, tmp_path): + test_path = tmp_path / "testfile.test" + result = runner.invoke( + incydr, + ["files", "download", TEST_SHA256, "--path", str(test_path)], + ) + assert result.exit_code == 0 + with open(test_path, "rb") as file: + content = file.read() + assert content == TEST_DATA + mock_file_download.check()