Skip to content
9 changes: 6 additions & 3 deletions lib/cuckoo/common/cleaners_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ def is_contiguous_range(ids: list) -> bool:
return (sorted_ids[-1] - sorted_ids[0] + 1) == len(ids)


def delete_bulk_tasks_n_folders(ids: list, delete_mongo: bool, delete_db_tasks=False, db_batch_size=None):
def delete_bulk_tasks_n_folders(ids: list, delete_mongo: bool, delete_db_tasks=False, db_batch_size=None, force=False):
if db_batch_size is None:
db_batch_size = DB_BATCH_SIZE

Expand All @@ -309,7 +309,7 @@ def delete_bulk_tasks_n_folders(ids: list, delete_mongo: bool, delete_db_tasks=F

# 2. Delete from MongoDB in larger, highly-efficient batches or range
if delete_mongo and ids:
if mongo_is_cluster():
if mongo_is_cluster() and not force:
response = input("You are deleting mongo data in cluster, are you sure you want to continue? y/n")
if response.lower() in ("n", "not"):
sys.exit()
Expand Down Expand Up @@ -473,7 +473,10 @@ def cuckoo_clean_banned_tasks():

tasks_list = db.list_tasks(status=TASK_BANNED)
ids = [task.id for task in tasks_list]
delete_bulk_tasks_n_folders(ids, delete_mongo=True, delete_db_tasks=True)
if ids:
delete_bulk_tasks_n_folders(ids, delete_mongo=True, delete_db_tasks=False, force=True)

db.delete_tasks(status=TASK_BANNED)


def cuckoo_clean_bson_suri_logs():
Expand Down
2 changes: 1 addition & 1 deletion lib/cuckoo/core/data/db_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class Error(Base):

id: Mapped[int] = mapped_column(primary_key=True)
message: Mapped[str] = mapped_column(String(MAX_LENGTH), nullable=False)
task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"), nullable=False)
task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="cascade"), nullable=False)
task: Mapped["Task"] = relationship(back_populates="errors")

def to_dict(self):
Expand Down
15 changes: 7 additions & 8 deletions lib/cuckoo/core/data/tasking.py
Original file line number Diff line number Diff line change
Expand Up @@ -1349,7 +1349,8 @@ def count_matching_tasks(self, category=None, status=None, not_status=None, visi
stmt = stmt.where(or_(*conds))

# 2. Execute the statement and return the single integer result.
return self.session.scalar(stmt)
with self.session.begin():
return self.session.scalar(stmt)

def list_tasks(
self,
Expand Down Expand Up @@ -1457,8 +1458,9 @@ def list_tasks(
if for_update:
stmt = stmt.with_for_update(of=Task)

tasks = self.session.scalars(stmt).all()
return tasks
with self.session.begin():
tasks = self.session.scalars(stmt).unique().all()
return tasks

def delete_task(self, task_id):
"""Delete information on a task.
Expand Down Expand Up @@ -1563,10 +1565,6 @@ def delete_tasks(
log.warning("No filters provided for delete_tasks. No tasks will be deleted.")
return True

# ToDo Transaction Handling
# The transaction logic (commit/rollback) is kept the same for a direct port,
# but the more idiomatic SQLAlchemy 2.0 approach would be to wrap the execution
# in a with self.session.begin(): block, which handles transactions automatically.
try:
with self.session.begin():
result = self.session.execute(delete_stmt)
Expand Down Expand Up @@ -1695,7 +1693,8 @@ def view_task(self, task_id, details=False) -> Optional[Task]:
)
else:
query = query.options(selectinload(Task.tags), joinedload(Task.sample))
return self.session.scalar(query)
with self.session.begin():
return self.session.scalar(query)

# This function is used by the runstatistics community module.
def add_statistics_to_task(self, task_id, details): # pragma: no cover
Expand Down
15 changes: 5 additions & 10 deletions lib/cuckoo/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@



SCHEMA_VERSION = "3a1b_tenant_visibility"
SCHEMA_VERSION = "errors_task_id_cascade"

log = logging.getLogger(__name__)
conf = Config("cuckoo")
Expand Down Expand Up @@ -155,19 +155,14 @@ def delete_tag_orphans(session, ctx):

# Deal with schema versioning.
# TODO: it's a little bit dirty, needs refactoring.
with self.session() as tmp_session:
with self.session.begin():
# Use the modern select() and scalar() to fetch the first object
query = select(AlembicVersion)
last = tmp_session.scalar(query)
last = self.session.scalar(query)

if last is None:
# Set database schema version (this part is unchanged)
tmp_session.add(AlembicVersion(version_num=SCHEMA_VERSION))
try:
tmp_session.commit()
except SQLAlchemyError as e: # pragma: no cover
tmp_session.rollback()
raise CuckooDatabaseError(f"Unable to set schema version: {e}")
self.session.add(AlembicVersion(version_num=SCHEMA_VERSION))
else:
# Check if db version is the expected one (this part is unchanged)
if last.version_num != SCHEMA_VERSION and schema_check and "pytest" not in sys.modules: # pragma: no cover
Expand Down Expand Up @@ -226,7 +221,7 @@ def _connect_database(self, connection_string):
)

except ImportError as e: # pragma: no cover
lib = e.message.rsplit(maxsplit=1)[-1]
lib = str(e).rsplit(maxsplit=1)[-1]
raise CuckooDependencyError(f"Missing database driver, unable to import {lib} (install with `pip install {lib}`)")

def _get_or_create(self, model, **kwargs):
Expand Down
27 changes: 27 additions & 0 deletions tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,33 @@ def test_delete_tasks(self, db: _Database, temp_filename):
assert len(tasks) == 1
assert tasks[0].id == t3

def test_delete_tasks_with_errors(self, db: _Database):
"""Test delete_tasks when tasks have associated Error records."""
if db.engine.dialect.name == "sqlite":
pytest.skip("Skipping cascade delete test on SQLite because SQLite does not enforce foreign key cascades by default.")

with db.session.begin():
t1 = db.add_url("https://1.com")
t2 = db.add_url("https://2.com")

# Add error messages associated with the tasks
db.add_error("Test error message 1", t1)
db.add_error("Test error message 2", t2)

# Assert that the tasks are deleted successfully
with db.session.begin():
assert db.delete_tasks(task_ids=[t1])

with db.session.begin():
# Check tasks and errors remaining
tasks = db.session.scalars(select(Task)).all()
assert len(tasks) == 1
assert tasks[0].id == t2

errors = db.session.scalars(select(Error)).all()
assert len(errors) == 1
assert errors[0].task_id == t2

def test_view_sample(self, db: _Database):
with db.session.begin():
samples = []
Expand Down
57 changes: 57 additions & 0 deletions utils/db_migration/versions/4_add_on_delete_cascade_to_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (C) 2010-2015 Cuckoo Foundation.
# This file is part of Cuckoo Sandbox - http://www.cuckoosandbox.org
# See the file 'docs/LICENSE' for copying permission.

"""add on delete cascade to errors fkey

Revision ID: errors_task_id_cascade
Revises: 3a1b_tenant_visibility
Create Date: 2026-08-23
"""

from alembic import op


revision = "errors_task_id_cascade"
down_revision = "3a1b_tenant_visibility"
branch_labels = None
depends_on = None


def upgrade():
try:
# Standard upgrade using batch operations for SQLite/MySQL/Postgres compatibility
with op.batch_alter_table("errors") as batch_op:
batch_op.drop_constraint("errors_task_id_fkey", type_="foreignkey")
except Exception:
try:
# Fallback for other standard naming conventions
with op.batch_alter_table("errors") as batch_op:
batch_op.drop_constraint("fk_errors_task_id_tasks", type_="foreignkey")
except Exception:
pass

with op.batch_alter_table("errors") as batch_op:
batch_op.create_foreign_key(
"errors_task_id_fkey",
"tasks",
["task_id"],
["id"],
ondelete="CASCADE"
)


def downgrade():
try:
with op.batch_alter_table("errors") as batch_op:
batch_op.drop_constraint("errors_task_id_fkey", type_="foreignkey")
except Exception:
pass

with op.batch_alter_table("errors") as batch_op:
batch_op.create_foreign_key(
"errors_task_id_fkey",
"tasks",
["task_id"],
["id"]
)
Loading