Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/cli/cmds/files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Files Commands

::: mkdocs-click
:module: _incydr_cli.cmds.files
:command: files
:list_subcommands:
5 changes: 5 additions & 0 deletions docs/sdk/clients/files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Files

::: _incydr_sdk.files.client.FilesV1
:docstring:
:members:
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
33 changes: 33 additions & 0 deletions src/_incydr_cli/cmds/files.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/_incydr_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/_incydr_sdk/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand Down
8 changes: 6 additions & 2 deletions src/_incydr_sdk/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions src/_incydr_sdk/files/client.py
Original file line number Diff line number Diff line change
@@ -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
)
102 changes: 63 additions & 39 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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
)
Loading