Skip to content
Open
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
10 changes: 7 additions & 3 deletions api_client/python/timesketch_api_client/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,17 @@ def delete(self, force_delete=False):
# Check the return status. If it's not a success (20x),
# error_message will raise a RuntimeError.
if not error.check_return_status(response, logger):
if response.status_code == definitions.HTTP_STATUS_CODE_NOT_FOUND:
error.error_message(
response,
message=f"Failed to delete sketch {self.id}",
error=error.NotFoundError,
)
error.error_message(
response,
message=f"Failed to delete sketch {self.id}",
error=RuntimeError,
)
else:
return error.check_return_status(response, logger)

return True

def add_to_acl(
Expand Down
56 changes: 49 additions & 7 deletions cli_client/python/timesketch_cli_client/commands/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from timesketch_cli_client.commands import attribute as attribute_command
from timesketch_api_client import search
from timesketch_api_client.error import NotFoundError


@click.group("sketch")
Expand Down Expand Up @@ -326,31 +327,72 @@ def delete_sketch(ctx: click.Context, force_delete: bool) -> None:
force_delete: If true, delete immediately.
"""
sketch = ctx.obj.sketch
# if sketch is archived, exit
if sketch.is_archived():

# Initialize with default values. Preserve cached sketch_name if it exists.
sketch_name = getattr(sketch, "_sketch_name", None) or "<Unknown/Deleted>"
sketch_desc = "N/A"
sketch_status = "N/A"
sketch_labels = "N/A"
timelines = []

try:
is_archived = sketch.is_archived()
except NotFoundError as e: # pylint: disable=unused-variable
click.echo(
f"Warning: Sketch {sketch.id} appears to be soft-deleted or inaccessible."
)
if not force_delete:
click.echo("If you want to permanently delete it, use --force_delete")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this guidance a bit misleading? We only enter this except NotFoundError block if the sketch doesn't exist at all, or if the user is a non-admin (since admins can check archived status on soft-deleted sketches without triggering a 404).

In both cases, telling them to try --force_delete will just lead to another failure (either because they aren't an admin, or because the sketch genuinely doesn't exist). Maybe we should just report it as not found or inaccessible without suggesting --force_delete?

ctx.exit(1)
is_archived = False

if is_archived:
click.echo("Error Sketch is archived")
ctx.exit(1)

try:
sketch_name = sketch.name
sketch_desc = sketch.description
sketch_status = sketch.status
sketch_labels = sketch.labels
timelines = sketch.list_timelines()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, the API (_get_sketch_for_admin) returns timelines: []. This means for admins, the dry-run here will show no timelines, but --force_delete will still permanently delete them from the DB.

That seems a bit risky because the admin won't see what they are actually deleting. Or is this intentional?

except NotFoundError as e: # pylint: disable=unused-variable
pass

# Dryrun:
if not force_delete:
click.echo("Would delete the following things (use --force_delete to execute)")

click.echo(
f"Sketch: {sketch.id} {sketch.name} {sketch.description} {sketch.status} Labels: {sketch.labels}" # pylint: disable=line-too-long
f"Sketch: {sketch.id} {sketch_name} {sketch_desc} {sketch_status} Labels: {sketch_labels}" # pylint: disable=line-too-long
)

for timeline in sketch.list_timelines():
for timeline in timelines:
timeline_desc = "N/A"
timeline_status = "N/A"
try:
# timeline.description and timeline.status lazy-load from the API.
timeline_desc = timeline.description
timeline_status = timeline.status
except NotFoundError as e: # pylint: disable=unused-variable
pass
click.echo(
f" Timeline: {timeline.id} {timeline.name} {timeline.description} {timeline.status}" # pylint: disable=line-too-long
f" Timeline: {timeline.id} {timeline.name} {timeline_desc} {timeline_status}" # pylint: disable=line-too-long
)

if force_delete:
# --- Check the response for success or error ---
try:
sketch.delete(force_delete=force_delete)
click.echo(f"Sketch {sketch.id} '{sketch.name}' successfully deleted.")
click.echo(f"Sketch {sketch.id} '{sketch_name}' successfully deleted.")
except NotFoundError:
Comment thread
jaegeral marked this conversation as resolved.
click.echo(
f"Failed to delete sketch {sketch.id} '{sketch_name}'. Error: Sketch was not found (perhaps already permanently deleted?)." # pylint: disable=line-too-long
)
ctx.exit(1)
except RuntimeError as e:
click.echo(
f"Failed to delete sketch {sketch.id} '{sketch.name}'. Error: {e}"
f"Failed to delete sketch {sketch.id} '{sketch_name}'. Error: {e}"
)
ctx.exit(1)

Expand Down
41 changes: 41 additions & 0 deletions end_to_end_tests/cli_client_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,47 @@ def test_cli_integration(self):
self.assertions.assertEqual(result.exit_code, 0, f"Failed: {result.output}")
self.assertions.assertEqual(result.output.strip(), "42")

def test_cli_sketch_delete_soft_deleted(self):
"""Tests that a soft-deleted sketch can be deleted via CLI."""
# Create a new sketch to be soft-deleted and then hard-deleted
sketch_name = f"cli_soft_delete_test_{uuid.uuid4().hex}"
sketch = self.api.create_sketch(name=sketch_name)

# We need a timeline to trigger the loop in sketch.py
self.import_timeline("evtx_part.csv", sketch=sketch)

# Soft delete the sketch
sketch.delete(force_delete=False)

# Get a fresh instance of the sketch so cached values are cleared
fresh_sketch = self.api.get_sketch(sketch.id)

# Now try to delete it via CLI (dry-run first, then force)
cli_ctx_obj = E2ECliContextObject(
api_client=self.api,
sketch_instance=fresh_sketch,
output_format="text",
)

# Dry-run
result = self.runner.invoke(sketch_group, ["delete"], obj=cli_ctx_obj)
self.assertions.assertEqual(
result.exit_code,
1,
f"CLI command 'sketch delete' (dry-run) failed to exit with 1 on soft-deleted sketch.\nOutput:\n{result.output}\nException:\n{result.exception}", # pylint: disable=line-too-long
)

# Force-delete
result_force = self.runner.invoke(
sketch_group, ["delete", "--force_delete"], obj=cli_ctx_obj
)
self.assertions.assertEqual(
result_force.exit_code,
1,
f"CLI command 'sketch delete --force_delete' unexpectedly succeeded on a soft-deleted sketch for a non-admin.\nOutput:\n{result_force.output}\nException:\n{result_force.exception}", # pylint: disable=line-too-long
)
self.assertions.assertIn("Failed to delete sketch", result_force.output)


# Register the new test class with the test manager
manager.EndToEndTestManager.register_test(CliClientE2ETest)
Loading