mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
Merge remote-tracking branch 'origin/PROWLER-1771-public-dynamic-provider-class-resolver' into PROWLER-1772-provider-type-storage-varchar
# Conflicts: # api/CHANGELOG.md
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from api.models import Provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.compliance_models import (
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
)
|
||||
from prowler.lib.check.models import CheckMetadata
|
||||
|
||||
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
|
||||
@@ -94,25 +96,22 @@ PROWLER_CHECKS = LazyChecksMapping()
|
||||
|
||||
|
||||
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
|
||||
"""List compliance frameworks the API can load for `provider_type`.
|
||||
"""List compliance framework identifiers available for `provider_type`.
|
||||
|
||||
The list is sourced from `Compliance.get_bulk` so that the names
|
||||
returned here are guaranteed to be loadable by the bulk loader. This
|
||||
prevents downstream key mismatches (e.g. CSV report generation iterating
|
||||
framework names and looking them up in the bulk dict).
|
||||
Includes both per-provider frameworks and universal top-level frameworks
|
||||
(e.g. ``dora``, ``csa_ccm_4.0``).
|
||||
|
||||
Args:
|
||||
provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve
|
||||
available compliance frameworks (e.g., "aws", "azure", "gcp", "m365").
|
||||
provider_type (Provider.ProviderChoices): The cloud provider type
|
||||
(e.g., "aws", "azure", "gcp", "m365").
|
||||
|
||||
Returns:
|
||||
list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available
|
||||
for the given provider.
|
||||
list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora").
|
||||
"""
|
||||
global AVAILABLE_COMPLIANCE_FRAMEWORKS
|
||||
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
|
||||
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list(
|
||||
Compliance.get_bulk(provider_type).keys()
|
||||
get_bulk_compliance_frameworks_universal(provider_type).keys()
|
||||
)
|
||||
|
||||
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
|
||||
@@ -139,18 +138,14 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
|
||||
"""
|
||||
Retrieve the Prowler compliance data for a specified provider type.
|
||||
|
||||
This function fetches the compliance frameworks and their associated
|
||||
requirements for the given cloud provider.
|
||||
|
||||
Args:
|
||||
provider_type (Provider.ProviderChoices): The provider type
|
||||
(e.g., 'aws', 'azure') for which to retrieve compliance data.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping compliance framework names to their respective
|
||||
Compliance objects for the specified provider.
|
||||
dict: Mapping of framework name to `ComplianceFramework` for the provider.
|
||||
"""
|
||||
return Compliance.get_bulk(provider_type)
|
||||
return get_bulk_compliance_frameworks_universal(provider_type)
|
||||
|
||||
|
||||
def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]:
|
||||
@@ -209,8 +204,8 @@ def load_prowler_checks(
|
||||
for compliance_name, compliance_data in prowler_compliance.get(
|
||||
provider_type, {}
|
||||
).items():
|
||||
for requirement in compliance_data.Requirements:
|
||||
for check in requirement.Checks:
|
||||
for requirement in compliance_data.requirements:
|
||||
for check in requirement.checks.get(provider_type, []):
|
||||
try:
|
||||
checks[provider_type][check].add(compliance_name)
|
||||
except KeyError:
|
||||
@@ -290,24 +285,40 @@ def generate_compliance_overview_template(
|
||||
requirements_status = {"passed": 0, "failed": 0, "manual": 0}
|
||||
total_requirements = 0
|
||||
|
||||
for requirement in compliance_data.Requirements:
|
||||
for requirement in compliance_data.requirements:
|
||||
total_requirements += 1
|
||||
total_checks = len(requirement.Checks)
|
||||
checks_dict = {check: None for check in requirement.Checks}
|
||||
provider_check_list = list(requirement.checks.get(provider_type, []))
|
||||
total_checks = len(provider_check_list)
|
||||
checks_dict = {check: None for check in provider_check_list}
|
||||
|
||||
req_status_val = "MANUAL" if total_checks == 0 else "PASS"
|
||||
|
||||
# MITRE attrs are wrapped under `_raw_attributes` by the
|
||||
# universal adapter — unwrap so consumers see the flat list.
|
||||
requirement_attributes = requirement.attributes
|
||||
if (
|
||||
isinstance(requirement_attributes, dict)
|
||||
and "_raw_attributes" in requirement_attributes
|
||||
):
|
||||
attributes_payload = list(requirement_attributes["_raw_attributes"])
|
||||
elif isinstance(requirement_attributes, dict):
|
||||
attributes_payload = (
|
||||
[dict(requirement_attributes)] if requirement_attributes else []
|
||||
)
|
||||
else:
|
||||
attributes_payload = [
|
||||
dict(attribute) for attribute in requirement_attributes
|
||||
]
|
||||
|
||||
# Build requirement dictionary
|
||||
requirement_dict = {
|
||||
"name": requirement.Name or requirement.Id,
|
||||
"description": requirement.Description,
|
||||
"tactics": getattr(requirement, "Tactics", []),
|
||||
"subtechniques": getattr(requirement, "SubTechniques", []),
|
||||
"platforms": getattr(requirement, "Platforms", []),
|
||||
"technique_url": getattr(requirement, "TechniqueURL", ""),
|
||||
"attributes": [
|
||||
dict(attribute) for attribute in requirement.Attributes
|
||||
],
|
||||
"name": requirement.name or requirement.id,
|
||||
"description": requirement.description,
|
||||
"tactics": requirement.tactics or [],
|
||||
"subtechniques": requirement.sub_techniques or [],
|
||||
"platforms": requirement.platforms or [],
|
||||
"technique_url": requirement.technique_url or "",
|
||||
"attributes": attributes_payload,
|
||||
"checks": checks_dict,
|
||||
"checks_status": {
|
||||
"pass": 0,
|
||||
@@ -325,15 +336,15 @@ def generate_compliance_overview_template(
|
||||
requirements_status["passed"] += 1
|
||||
|
||||
# Add requirement to compliance requirements
|
||||
compliance_requirements[requirement.Id] = requirement_dict
|
||||
compliance_requirements[requirement.id] = requirement_dict
|
||||
|
||||
# Build compliance dictionary
|
||||
compliance_dict = {
|
||||
"framework": compliance_data.Framework,
|
||||
"name": compliance_data.Name,
|
||||
"version": compliance_data.Version,
|
||||
"framework": compliance_data.framework,
|
||||
"name": compliance_data.name,
|
||||
"version": compliance_data.version,
|
||||
"provider": provider_type,
|
||||
"description": compliance_data.Description,
|
||||
"description": compliance_data.description,
|
||||
"requirements": compliance_requirements,
|
||||
"requirements_status": requirements_status,
|
||||
"total_requirements": total_requirements,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Recover orphaned allowlisted Celery tasks whose worker is gone and mark "
|
||||
"other stale task results terminal. Single-flight via a Postgres advisory lock."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--grace-minutes",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Skip tasks started within this window (worker may still register).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-attempts",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Give up re-running a task after this many recovery attempts (scans are marked FAILED).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Detect and report orphans without revoking or re-enqueuing.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
result = reconcile_orphans(
|
||||
grace_minutes=options["grace_minutes"],
|
||||
max_attempts=options["max_attempts"],
|
||||
dry_run=options["dry_run"],
|
||||
)
|
||||
|
||||
if not result.get("acquired"):
|
||||
self.stdout.write("Reconcile skipped: another run holds the lock.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Orphan reconcile complete: "
|
||||
f"recovered={len(result.get('recovered', []))} "
|
||||
f"failed={len(result.get('failed', []))} "
|
||||
f"skipped(in-flight)={len(result.get('skipped', []))}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.15 on 2026-05-30 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0093_okta_provider"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scan",
|
||||
name="recovery_count",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
TASK_NAME = "reconcile-orphan-tasks"
|
||||
INTERVAL_MINUTES = 2
|
||||
|
||||
|
||||
def create_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=INTERVAL_MINUTES,
|
||||
period="minutes",
|
||||
)
|
||||
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=TASK_NAME,
|
||||
defaults={
|
||||
"task": TASK_NAME,
|
||||
"interval": schedule,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
PeriodicTask.objects.filter(name=TASK_NAME).delete()
|
||||
|
||||
# Clean up the schedule if no other task references it
|
||||
IntervalSchedule.objects.filter(
|
||||
every=INTERVAL_MINUTES,
|
||||
period="minutes",
|
||||
periodictask__isnull=True,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0094_scan_recovery_count"),
|
||||
("django_celery_beat", "0019_alter_periodictasks_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_periodic_task, delete_periodic_task),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.rls
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0095_reconcile_orphan_tasks_periodic_task"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="JiraIssueDispatch",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("finding_id", models.UUIDField()),
|
||||
(
|
||||
"integration",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="jira_dispatches",
|
||||
to="api.integration",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "jira_issue_dispatches",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="jiraissuedispatch",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id", "integration_id", "finding_id"),
|
||||
name="unique_jira_issue_dispatch",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="jiraissuedispatch",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_jiraissuedispatch",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
+1
-1
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("api", "0093_okta_provider"),
|
||||
("api", "0096_jiraissuedispatch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
+3
-3
@@ -4,14 +4,14 @@ from django.db import migrations
|
||||
class Migration(migrations.Migration):
|
||||
"""Synchronous backfill of the `provider_str` shadow column.
|
||||
|
||||
A single UPDATE fills rows that predate the 0094 trigger. The providers
|
||||
A single UPDATE fills rows that predate the 0097 trigger. The providers
|
||||
table is small, so this is safe inline and guarantees the column is fully
|
||||
populated before 0096 sets it NOT NULL (no race with an async backfill).
|
||||
populated before 0099 sets it NOT NULL (no race with an async backfill).
|
||||
Runs on the migration connection, which is exempt from RLS.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("api", "0094_provider_str_shadow_column"),
|
||||
("api", "0097_provider_str_shadow_column"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
+1
-1
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("api", "0095_backfill_provider_str"),
|
||||
("api", "0098_backfill_provider_str"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -678,6 +678,9 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
|
||||
unique_resource_count = models.IntegerField(default=0)
|
||||
progress = models.IntegerField(default=0)
|
||||
# Incremented by the scan-specific orphan-recovery path each time this scan is
|
||||
# re-pointed to a fresh task; for observability (the retry cap is a Valkey counter).
|
||||
recovery_count = models.IntegerField(default=0)
|
||||
scanner_args = models.JSONField(default=dict)
|
||||
duration = models.IntegerField(null=True, blank=True)
|
||||
scheduled_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -2010,6 +2013,35 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
|
||||
]
|
||||
|
||||
|
||||
class JiraIssueDispatch(RowLevelSecurityProtectedModel):
|
||||
"""Tracks findings already sent to a Jira integration.
|
||||
|
||||
Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with
|
||||
an existing dispatch row are skipped, so no duplicate issues are created.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
integration = models.ForeignKey(
|
||||
Integration, on_delete=models.CASCADE, related_name="jira_dispatches"
|
||||
)
|
||||
finding_id = models.UUIDField()
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "jira_issue_dispatches"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["tenant_id", "integration_id", "finding_id"],
|
||||
name="unique_jira_issue_dispatch",
|
||||
),
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SAMLToken(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
|
||||
@@ -13137,8 +13137,59 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: CSV file containing the compliance report
|
||||
'202':
|
||||
description: The task is in progress
|
||||
'403':
|
||||
description: There is a problem with credentials
|
||||
'404':
|
||||
description: Compliance report not found
|
||||
description: Compliance report not found, or the scan has no reports yet
|
||||
/api/v1/scans/{id}/compliance/{name}/ocsf:
|
||||
get:
|
||||
operationId: scans_compliance_ocsf_retrieve
|
||||
description: Download a specific compliance report as an OCSF JSON file. Only
|
||||
universal frameworks that declare an output configuration produce this artifact
|
||||
(currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404.
|
||||
summary: Retrieve compliance report as OCSF JSON
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[scan-reports]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- id
|
||||
- name
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this scan.
|
||||
required: true
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
description: The compliance report name, like 'dora'
|
||||
required: true
|
||||
tags:
|
||||
- Scan
|
||||
security:
|
||||
- JWT or API Key: []
|
||||
responses:
|
||||
'200':
|
||||
description: OCSF JSON file containing the compliance report
|
||||
'202':
|
||||
description: The task is in progress
|
||||
'403':
|
||||
description: There is a problem with credentials
|
||||
'404':
|
||||
description: Compliance report not found, the framework does not provide
|
||||
an OCSF export, or the scan has no reports yet
|
||||
/api/v1/scans/{id}/csa:
|
||||
get:
|
||||
operationId: scans_csa_retrieve
|
||||
|
||||
@@ -12,7 +12,9 @@ from api.compliance import (
|
||||
load_prowler_checks,
|
||||
)
|
||||
from api.models import Provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.compliance_models import (
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
)
|
||||
|
||||
|
||||
class TestCompliance:
|
||||
@@ -28,16 +30,16 @@ class TestCompliance:
|
||||
assert set(checks) == {"check1", "check2", "check3"}
|
||||
mock_check_metadata.get_bulk.assert_called_once_with(provider_type)
|
||||
|
||||
@patch("api.compliance.Compliance")
|
||||
def test_get_prowler_provider_compliance(self, mock_compliance):
|
||||
@patch("api.compliance.get_bulk_compliance_frameworks_universal")
|
||||
def test_get_prowler_provider_compliance(self, mock_get_bulk):
|
||||
provider_type = Provider.ProviderChoices.AWS
|
||||
mock_compliance.get_bulk.return_value = {
|
||||
mock_get_bulk.return_value = {
|
||||
"compliance1": MagicMock(),
|
||||
"compliance2": MagicMock(),
|
||||
}
|
||||
compliance_data = get_prowler_provider_compliance(provider_type)
|
||||
assert compliance_data == mock_compliance.get_bulk.return_value
|
||||
mock_compliance.get_bulk.assert_called_once_with(provider_type)
|
||||
assert compliance_data == mock_get_bulk.return_value
|
||||
mock_get_bulk.assert_called_once_with(provider_type)
|
||||
|
||||
@patch("api.compliance.get_prowler_provider_checks")
|
||||
@patch("api.models.Provider.ProviderChoices")
|
||||
@@ -51,9 +53,9 @@ class TestCompliance:
|
||||
prowler_compliance = {
|
||||
"aws": {
|
||||
"compliance1": MagicMock(
|
||||
Requirements=[
|
||||
requirements=[
|
||||
MagicMock(
|
||||
Checks=["check1", "check2"],
|
||||
checks={"aws": ["check1", "check2"]},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -167,35 +169,38 @@ class TestCompliance:
|
||||
def test_generate_compliance_overview_template(self, mock_provider_choices):
|
||||
mock_provider_choices.values = ["aws"]
|
||||
|
||||
# ``name`` is a reserved MagicMock kwarg (it labels the mock for repr,
|
||||
# it does NOT set a ``.name`` attribute), so it must be assigned
|
||||
# explicitly after construction.
|
||||
requirement1 = MagicMock(
|
||||
Id="requirement1",
|
||||
Name="Requirement 1",
|
||||
Description="Description of requirement 1",
|
||||
Attributes=[],
|
||||
Checks=["check1", "check2"],
|
||||
Tactics=["tactic1"],
|
||||
SubTechniques=["subtechnique1"],
|
||||
Platforms=["platform1"],
|
||||
TechniqueURL="https://example.com",
|
||||
id="requirement1",
|
||||
description="Description of requirement 1",
|
||||
attributes=[],
|
||||
checks={"aws": ["check1", "check2"]},
|
||||
tactics=["tactic1"],
|
||||
sub_techniques=["subtechnique1"],
|
||||
platforms=["platform1"],
|
||||
technique_url="https://example.com",
|
||||
)
|
||||
requirement1.name = "Requirement 1"
|
||||
requirement2 = MagicMock(
|
||||
Id="requirement2",
|
||||
Name="Requirement 2",
|
||||
Description="Description of requirement 2",
|
||||
Attributes=[],
|
||||
Checks=[],
|
||||
Tactics=[],
|
||||
SubTechniques=[],
|
||||
Platforms=[],
|
||||
TechniqueURL="",
|
||||
id="requirement2",
|
||||
description="Description of requirement 2",
|
||||
attributes=[],
|
||||
checks={"aws": []},
|
||||
tactics=[],
|
||||
sub_techniques=[],
|
||||
platforms=[],
|
||||
technique_url="",
|
||||
)
|
||||
requirement2.name = "Requirement 2"
|
||||
compliance1 = MagicMock(
|
||||
Requirements=[requirement1, requirement2],
|
||||
Framework="Framework 1",
|
||||
Version="1.0",
|
||||
Description="Description of compliance1",
|
||||
Name="Compliance 1",
|
||||
requirements=[requirement1, requirement2],
|
||||
framework="Framework 1",
|
||||
version="1.0",
|
||||
description="Description of compliance1",
|
||||
)
|
||||
compliance1.name = "Compliance 1"
|
||||
prowler_compliance = {"aws": {"compliance1": compliance1}}
|
||||
|
||||
template = generate_compliance_overview_template(prowler_compliance)
|
||||
@@ -271,24 +276,28 @@ def reset_compliance_cache():
|
||||
|
||||
class TestGetComplianceFrameworks:
|
||||
def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache):
|
||||
with patch("api.compliance.Compliance") as mock_compliance:
|
||||
mock_compliance.get_bulk.return_value = {
|
||||
with patch(
|
||||
"api.compliance.get_bulk_compliance_frameworks_universal"
|
||||
) as mock_get_bulk:
|
||||
mock_get_bulk.return_value = {
|
||||
"cis_1.4_aws": MagicMock(),
|
||||
"mitre_attack_aws": MagicMock(),
|
||||
}
|
||||
result = get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
|
||||
assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"]
|
||||
mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
mock_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
|
||||
def test_caches_result_per_provider(self, reset_compliance_cache):
|
||||
with patch("api.compliance.Compliance") as mock_compliance:
|
||||
mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
|
||||
with patch(
|
||||
"api.compliance.get_bulk_compliance_frameworks_universal"
|
||||
) as mock_get_bulk:
|
||||
mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
|
||||
get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
|
||||
# Cached after first call.
|
||||
assert mock_compliance.get_bulk.call_count == 1
|
||||
assert mock_get_bulk.call_count == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type",
|
||||
@@ -296,17 +305,19 @@ class TestGetComplianceFrameworks:
|
||||
)
|
||||
def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type):
|
||||
"""Regression for CLOUD-API-40S: every name returned by
|
||||
``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``.
|
||||
``get_compliance_frameworks`` must be loadable via
|
||||
``get_bulk_compliance_frameworks_universal``.
|
||||
|
||||
A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in
|
||||
``generate_outputs_task`` after universal/multi-provider compliance
|
||||
JSONs were introduced at the top-level ``prowler/compliance/`` path.
|
||||
"""
|
||||
bulk_keys = set(Compliance.get_bulk(provider_type).keys())
|
||||
bulk_keys = set(get_bulk_compliance_frameworks_universal(provider_type).keys())
|
||||
listed = set(get_compliance_frameworks(provider_type))
|
||||
|
||||
missing = listed - bulk_keys
|
||||
assert not missing, (
|
||||
f"get_compliance_frameworks({provider_type!r}) returned names not "
|
||||
f"loadable by Compliance.get_bulk: {sorted(missing)}"
|
||||
f"loadable by get_bulk_compliance_frameworks_universal: "
|
||||
f"{sorted(missing)}"
|
||||
)
|
||||
|
||||
@@ -24,9 +24,11 @@ from conftest import (
|
||||
today_after_n_days,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
@@ -64,6 +66,7 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
@@ -3856,16 +3859,20 @@ class TestScanViewSet:
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
dummy_task.id = "dummy-task-id"
|
||||
dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
|
||||
|
||||
with (
|
||||
patch("api.v1.views.Task.objects.get", return_value=dummy_task),
|
||||
patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
),
|
||||
with patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
):
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
@@ -4186,6 +4193,88 @@ class TestScanViewSet:
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"] == presigned_url
|
||||
|
||||
def test_compliance_s3_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""When several files match, the most recently modified one is served."""
|
||||
scan = scans_fixture[0]
|
||||
bucket = "bucket"
|
||||
scan.output_location = f"s3://{bucket}/path/scan.zip"
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
return {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": old_key,
|
||||
"LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
},
|
||||
{
|
||||
"Key": latest_key,
|
||||
"LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
assert Params["Key"] == latest_key
|
||||
return "https://test-bucket.s3.amazonaws.com/latest"
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
|
||||
|
||||
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"].endswith("/latest")
|
||||
|
||||
def test_compliance_local_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""The local branch serves the most recently modified matching file."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
comp_dir = Path(tmp) / "reports" / "compliance"
|
||||
comp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
old_file.write_bytes(b"old")
|
||||
latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
latest_file.write_bytes(b"latest")
|
||||
# Make `latest_file` newer regardless of creation order.
|
||||
os.utime(old_file, (1_700_000_000, 1_700_000_000))
|
||||
os.utime(latest_file, (1_700_000_100, 1_700_000_100))
|
||||
|
||||
scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(old_file), str(latest_file)],
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
|
||||
)
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.content == b"latest"
|
||||
assert resp["Content-Disposition"].endswith(
|
||||
f'filename="{latest_file.name}"'
|
||||
)
|
||||
|
||||
def test_compliance_s3_not_found(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
@@ -4294,18 +4383,24 @@ class TestScanViewSet:
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_none_if_task_not_executing(
|
||||
self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
mock_task_get.return_value = task
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
mock_task_serializer.return_value.data = {
|
||||
"id": str(task.id),
|
||||
"state": StateChoices.COMPLETED,
|
||||
@@ -4326,6 +4421,7 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
@@ -4346,6 +4442,51 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.data["id"] == str(task.id)
|
||||
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_latest_task(
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
"""With several scan-report tasks for the scan, the most recent is used."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
old_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
new_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
# `inserted_at` is `auto_now_add`, and within the test transaction the DB
|
||||
# `now()` is constant, so force distinct timestamps to make order_by stable.
|
||||
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
|
||||
Task.objects.filter(pk=new_task.pk).update(
|
||||
inserted_at=base + timedelta(hours=1)
|
||||
)
|
||||
|
||||
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
|
||||
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
|
||||
)
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert str(new_task.id) in response["Content-Location"]
|
||||
assert str(old_task.id) not in response["Content-Location"]
|
||||
|
||||
@patch("api.v1.views.get_s3_client")
|
||||
@patch("api.v1.views.sentry_sdk.capture_exception")
|
||||
def test_compliance_list_objects_client_error(
|
||||
@@ -6916,6 +7057,80 @@ class TestFindingViewSet:
|
||||
== findings_fixture[0].status
|
||||
)
|
||||
|
||||
def test_findings_list_resource_tags_no_n_plus_one(
|
||||
self, authenticated_client, findings_fixture
|
||||
):
|
||||
"""Listing findings must load every resource's tags in a constant
|
||||
number of queries, no matter how many findings/resources are returned.
|
||||
|
||||
This guards ``FindingViewSet._optimize_tags_loading`` against
|
||||
regressions that would reintroduce one extra query per resource (the
|
||||
N+1 the prefetch was added to remove).
|
||||
"""
|
||||
scan = findings_fixture[0].scan
|
||||
tenant_id = findings_fixture[0].tenant_id
|
||||
provider = scan.provider
|
||||
|
||||
def _create_finding_with_tagged_resource(index):
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
|
||||
name=f"N+1 Instance {index}",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
resource.upsert_or_delete_tags(
|
||||
[
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
key=f"key-{index}",
|
||||
value=f"value-{index}",
|
||||
)
|
||||
]
|
||||
)
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"n_plus_one_finding_{index}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="n+1 status",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
check_id="test_check_id",
|
||||
check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
)
|
||||
finding.add_resources([resource])
|
||||
return finding
|
||||
|
||||
params = {"filter[inserted_at]": TODAY, "include": "resources"}
|
||||
|
||||
# Baseline: the two findings provided by the fixture.
|
||||
with CaptureQueriesContext(connection) as baseline:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Add more findings, each with its own resource carrying tags.
|
||||
extra_findings = 5
|
||||
for index in range(extra_findings):
|
||||
_create_finding_with_tagged_resource(index)
|
||||
|
||||
with CaptureQueriesContext(connection) as scaled:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
|
||||
|
||||
# The query count must not grow with the number of findings/resources.
|
||||
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
|
||||
"Resource tags are not being prefetched: "
|
||||
f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
|
||||
f"findings vs {len(scaled.captured_queries)} for "
|
||||
f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
|
||||
"in FindingViewSet._optimize_tags_loading."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"include_values, expected_resources",
|
||||
[
|
||||
@@ -9345,6 +9560,16 @@ class TestComplianceOverviewViewSet:
|
||||
assert "platforms" in attributes["attributes"]["technique_details"]
|
||||
assert "technique_url" in attributes["attributes"]["technique_details"]
|
||||
|
||||
# Guard against the `_raw_attributes` wrapper leaking through —
|
||||
# the UI reads metadata[i].Category / .AWSService directly.
|
||||
metadata = attributes["attributes"]["metadata"]
|
||||
assert isinstance(metadata, list) and len(metadata) > 0
|
||||
first_attr = metadata[0]
|
||||
assert isinstance(first_attr, dict)
|
||||
assert "_raw_attributes" not in first_attr
|
||||
assert "Category" in first_attr
|
||||
assert "AWSService" in first_attr
|
||||
|
||||
def test_compliance_overview_attributes_missing_compliance_id(
|
||||
self, authenticated_client
|
||||
):
|
||||
|
||||
+138
-55
@@ -116,6 +116,7 @@ from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
|
||||
from api.compliance import (
|
||||
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
|
||||
get_compliance_frameworks,
|
||||
get_prowler_provider_compliance,
|
||||
)
|
||||
from api.constants import SEVERITY_ORDER
|
||||
from api.db_router import MainRouter
|
||||
@@ -1849,7 +1850,42 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
200: OpenApiResponse(
|
||||
description="CSV file containing the compliance report"
|
||||
),
|
||||
404: OpenApiResponse(description="Compliance report not found"),
|
||||
202: OpenApiResponse(description="The task is in progress"),
|
||||
403: OpenApiResponse(description="There is a problem with credentials"),
|
||||
404: OpenApiResponse(
|
||||
description="Compliance report not found, or the scan has no reports yet"
|
||||
),
|
||||
},
|
||||
request=None,
|
||||
),
|
||||
compliance_ocsf=extend_schema(
|
||||
tags=["Scan"],
|
||||
summary="Retrieve compliance report as OCSF JSON",
|
||||
description=(
|
||||
"Download a specific compliance report as an OCSF JSON file. "
|
||||
"Only universal frameworks that declare an output configuration "
|
||||
"produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any "
|
||||
"other framework returns 404."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="name",
|
||||
type=str,
|
||||
location=OpenApiParameter.PATH,
|
||||
required=True,
|
||||
description="The compliance report name, like 'dora'",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="OCSF JSON file containing the compliance report"
|
||||
),
|
||||
202: OpenApiResponse(description="The task is in progress"),
|
||||
403: OpenApiResponse(description="There is a problem with credentials"),
|
||||
404: OpenApiResponse(
|
||||
description="Compliance report not found, the framework does "
|
||||
"not provide an OCSF export, or the scan has no reports yet"
|
||||
),
|
||||
},
|
||||
request=None,
|
||||
),
|
||||
@@ -1992,35 +2028,23 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
return queryset.select_related("provider", "task")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
return ScanCreateSerializer
|
||||
elif self.action == "partial_update":
|
||||
if self.action == "partial_update":
|
||||
return ScanUpdateSerializer
|
||||
elif self.action == "report":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
return ScanReportSerializer
|
||||
elif self.action == "compliance":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
return ScanComplianceReportSerializer
|
||||
elif self.action == "threatscore":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "ens":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "nis2":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "csa":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "cis":
|
||||
|
||||
action_defaults = {
|
||||
"create": ScanCreateSerializer,
|
||||
"report": ScanReportSerializer,
|
||||
"compliance": ScanComplianceReportSerializer,
|
||||
"compliance_ocsf": ScanComplianceReportSerializer,
|
||||
}
|
||||
response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"}
|
||||
|
||||
if self.action in action_defaults or self.action in response_only_actions:
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
if self.action in action_defaults:
|
||||
return action_defaults[self.action]
|
||||
|
||||
return super().get_serializer_class()
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
@@ -2059,12 +2083,17 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
|
||||
task = scan_instance.task
|
||||
else:
|
||||
try:
|
||||
task = Task.objects.get(
|
||||
# A scan can have several `scan-report` tasks (e.g. re-runs); take the
|
||||
# most recent one. `.first()` also avoids `MultipleObjectsReturned`.
|
||||
task = (
|
||||
Task.objects.filter(
|
||||
task_runner_task__task_name="scan-report",
|
||||
task_runner_task__task_kwargs__contains=str(scan_instance.id),
|
||||
)
|
||||
except Task.DoesNotExist:
|
||||
.order_by("-inserted_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
@@ -2139,27 +2168,32 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
contents = resp.get("Contents", [])
|
||||
keys = []
|
||||
matches = []
|
||||
for obj in contents:
|
||||
key = obj["Key"]
|
||||
key_basename = os.path.basename(key)
|
||||
if any(ch in suffix for ch in ("*", "?", "[")):
|
||||
if fnmatch.fnmatch(key_basename, suffix):
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key_basename == suffix:
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key.endswith(suffix):
|
||||
# Backward compatibility if suffix already includes directories
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
matches.append(obj)
|
||||
if not matches:
|
||||
return Response(
|
||||
{
|
||||
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# path_pattern here is prefix, but in compliance we build correct suffix check before
|
||||
key = keys[0]
|
||||
# Return the most recently modified match (latest report) when
|
||||
# several files share the prefix/suffix. `list_objects_v2` always
|
||||
# returns `LastModified`; the fallback keeps ordering deterministic
|
||||
# if it is ever absent.
|
||||
key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
|
||||
"Key"
|
||||
]
|
||||
else:
|
||||
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
|
||||
key = path_pattern
|
||||
@@ -2209,7 +2243,9 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
filepath = files[0]
|
||||
# Return the most recently modified match (latest report) when the
|
||||
# pattern resolves to several files.
|
||||
filepath = max(files, key=os.path.getmtime)
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -2257,20 +2293,16 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "application/x-zip-compressed")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="compliance/(?P<name>[^/]+)",
|
||||
url_name="compliance",
|
||||
)
|
||||
def compliance(self, request, pk=None, name=None):
|
||||
scan = self.get_object()
|
||||
if name not in get_compliance_frameworks(scan.provider.provider):
|
||||
return Response(
|
||||
{"detail": f"Compliance '{name}' not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
def _serve_compliance_artifact(self, scan, name, file_extension, content_type):
|
||||
"""Resolve and serve a per-framework compliance artifact from disk/S3.
|
||||
|
||||
Shared by the CSV and OCSF compliance download actions. Both are
|
||||
path-based (no query params) on purpose: ``get_object`` runs
|
||||
``filter_queryset``, which triggers JSON:API's
|
||||
``QueryParameterValidationFilter`` and 400s on any non-JSON:API
|
||||
query param, so a ``?format=`` / ``?type=`` selector is not viable
|
||||
here — the format is encoded in the route instead.
|
||||
"""
|
||||
running_resp = self._get_task_status(scan)
|
||||
if running_resp:
|
||||
return running_resp
|
||||
@@ -2287,25 +2319,66 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
|
||||
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
|
||||
prefix = os.path.join(
|
||||
os.path.dirname(key_prefix), "compliance", f"{name}.csv"
|
||||
os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}"
|
||||
)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="text/csv",
|
||||
content_type=content_type,
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "compliance", f"*_{name}.csv")
|
||||
pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "text/csv")
|
||||
return self._serve_file(content, filename, content_type)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="compliance/(?P<name>[^/]+)",
|
||||
url_name="compliance",
|
||||
)
|
||||
def compliance(self, request, pk=None, name=None):
|
||||
scan = self.get_object()
|
||||
if name not in get_compliance_frameworks(scan.provider.provider):
|
||||
return Response(
|
||||
{"detail": f"Compliance '{name}' not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return self._serve_compliance_artifact(scan, name, "csv", "text/csv")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="compliance/(?P<name>[^/]+)/ocsf",
|
||||
url_name="compliance-ocsf",
|
||||
)
|
||||
def compliance_ocsf(self, request, pk=None, name=None):
|
||||
scan = self.get_object()
|
||||
if name not in get_compliance_frameworks(scan.provider.provider):
|
||||
return Response(
|
||||
{"detail": f"Compliance '{name}' not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
universal_bulk = get_prowler_provider_compliance(scan.provider.provider)
|
||||
framework_obj = universal_bulk.get(name)
|
||||
if not (framework_obj and getattr(framework_obj, "outputs", None)):
|
||||
return Response(
|
||||
{"detail": f"Compliance '{name}' does not provide an OCSF export."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return self._serve_compliance_artifact(
|
||||
scan, name, "ocsf.json", "application/json"
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
@@ -3749,6 +3822,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
return queryset
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
def _optimize_tags_loading(self, queryset):
|
||||
"""Prefetch resource tags to avoid N+1 queries when serializing findings"""
|
||||
return queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"resources__tags",
|
||||
queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
|
||||
to_attr="prefetched_tags",
|
||||
)
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
return self.paginate_by_pk(
|
||||
|
||||
@@ -26,6 +26,61 @@ celery_app.conf.result_backend_transport_options = {
|
||||
}
|
||||
celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT
|
||||
|
||||
# Durable delivery: keep the message until the task finishes, so a worker killed
|
||||
# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a
|
||||
# time so a crash exposes at most one extra reserved message.
|
||||
celery_app.conf.task_acks_late = True
|
||||
celery_app.conf.task_reject_on_worker_lost = True
|
||||
celery_app.conf.worker_prefetch_multiplier = env.int(
|
||||
"DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1
|
||||
)
|
||||
# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before
|
||||
# it is forcefully killed (Celery 5.5+ soft shutdown).
|
||||
celery_app.conf.worker_soft_shutdown_timeout = env.int(
|
||||
"DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60
|
||||
)
|
||||
# Bound execution so a blocked task cannot pin a worker forever. Connection
|
||||
# checks get a tight limit; scans and provider/tenant deletions can legitimately
|
||||
# run for more than a day on large tenants, so they get a much higher cap.
|
||||
# The default for every other task is set as the global limit, not as a "*"
|
||||
# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in
|
||||
# task_annotations would silently overwrite every specific limit defined below.
|
||||
_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60)
|
||||
_TASK_SOFT_LIMIT = env.int(
|
||||
"DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600
|
||||
)
|
||||
_LONG_TASK_HARD_LIMIT = env.int(
|
||||
"DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60
|
||||
)
|
||||
_LONG_TASK_SOFT_LIMIT = env.int(
|
||||
"DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600
|
||||
)
|
||||
celery_app.conf.task_time_limit = _TASK_HARD_LIMIT
|
||||
celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT
|
||||
celery_app.conf.task_annotations = {
|
||||
**{
|
||||
name: {"soft_time_limit": 60, "time_limit": 120}
|
||||
for name in (
|
||||
"provider-connection-check",
|
||||
"integration-connection-check",
|
||||
"lighthouse-connection-check",
|
||||
"lighthouse-provider-connection-check",
|
||||
)
|
||||
},
|
||||
**{
|
||||
name: {
|
||||
"soft_time_limit": _LONG_TASK_SOFT_LIMIT,
|
||||
"time_limit": _LONG_TASK_HARD_LIMIT,
|
||||
}
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
celery_app.autodiscover_tasks(["api"])
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from celery import current_app, states
|
||||
from celery import states
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES
|
||||
from tasks.jobs.attack_paths.db_utils import (
|
||||
_mark_scan_finished,
|
||||
recover_graph_data_ready,
|
||||
)
|
||||
from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive
|
||||
from tasks.jobs.orphan_recovery import revoke_task as _revoke_task
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_router import MainRouter
|
||||
@@ -150,32 +152,6 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
|
||||
return cleaned_up
|
||||
|
||||
|
||||
def _is_worker_alive(worker: str) -> bool:
|
||||
"""Ping a specific Celery worker. Returns `True` if it responds or on error."""
|
||||
try:
|
||||
response = current_app.control.inspect(destination=[worker], timeout=1.0).ping()
|
||||
return response is not None and worker in response
|
||||
except Exception:
|
||||
logger.exception(f"Failed to ping worker {worker}, treating as alive")
|
||||
return True
|
||||
|
||||
|
||||
def _revoke_task(task_result, terminate: bool = True) -> None:
|
||||
"""Revoke a Celery task. Non-fatal on failure.
|
||||
|
||||
`terminate=True` SIGTERMs the worker if the task is mid-execution; use
|
||||
for EXECUTING cleanup. `terminate=False` only marks the task id revoked
|
||||
across workers, so any worker pulling the queued message discards it;
|
||||
use for SCHEDULED cleanup where the task hasn't run yet.
|
||||
"""
|
||||
try:
|
||||
kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
|
||||
current_app.control.revoke(task_result.task_id, **kwargs)
|
||||
logger.info(f"Revoked task {task_result.task_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
|
||||
|
||||
def _cleanup_scan(scan, task_result, reason: str) -> bool:
|
||||
"""
|
||||
Clean up a single stale `AttackPathsScan`:
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.db_utils import batch_delete, rls_transaction
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
JiraIssueDispatch,
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
@@ -80,6 +81,14 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
|
||||
deletion_steps = [
|
||||
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
|
||||
(
|
||||
"Jira Issue Dispatches",
|
||||
JiraIssueDispatch.objects.filter(
|
||||
finding_id__in=Finding.all_objects.filter(
|
||||
scan__provider=instance
|
||||
).values_list("id", flat=True)
|
||||
),
|
||||
),
|
||||
("Findings", Finding.all_objects.filter(scan__provider=instance)),
|
||||
("Resources", Resource.all_objects.filter(provider=instance)),
|
||||
("Scans", Scan.all_objects.filter(provider=instance)),
|
||||
|
||||
@@ -39,11 +39,6 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
|
||||
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
|
||||
GoogleWorkspaceCISASCuBA,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
|
||||
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
|
||||
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
|
||||
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
|
||||
@@ -102,7 +97,6 @@ COMPLIANCE_CLASS_MAP = {
|
||||
(lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS),
|
||||
(lambda name: name.startswith("ccc_"), CCC_AWS),
|
||||
(lambda name: name.startswith("c5_"), AWSC5),
|
||||
(lambda name: name.startswith("csa_"), AWSCSA),
|
||||
(lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS),
|
||||
],
|
||||
"azure": [
|
||||
@@ -113,7 +107,6 @@ COMPLIANCE_CLASS_MAP = {
|
||||
(lambda name: name.startswith("ccc_"), CCC_Azure),
|
||||
(lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure),
|
||||
(lambda name: name == "c5_azure", AzureC5),
|
||||
(lambda name: name.startswith("csa_"), AzureCSA),
|
||||
],
|
||||
"gcp": [
|
||||
(lambda name: name.startswith("cis_"), GCPCIS),
|
||||
@@ -123,7 +116,6 @@ COMPLIANCE_CLASS_MAP = {
|
||||
(lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP),
|
||||
(lambda name: name.startswith("ccc_"), CCC_GCP),
|
||||
(lambda name: name == "c5_gcp", GCPC5),
|
||||
(lambda name: name.startswith("csa_"), GCPCSA),
|
||||
],
|
||||
"kubernetes": [
|
||||
(lambda name: name.startswith("cis_"), KubernetesCIS),
|
||||
@@ -152,11 +144,9 @@ COMPLIANCE_CLASS_MAP = {
|
||||
"image": [],
|
||||
"oraclecloud": [
|
||||
(lambda name: name.startswith("cis_"), OracleCloudCIS),
|
||||
(lambda name: name.startswith("csa_"), OracleCloudCSA),
|
||||
],
|
||||
"alibabacloud": [
|
||||
(lambda name: name.startswith("cis_"), AlibabaCloudCIS),
|
||||
(lambda name: name.startswith("csa_"), AlibabaCloudCSA),
|
||||
(
|
||||
lambda name: name == "prowler_threatscore_alibabacloud",
|
||||
ProwlerThreatScoreAlibaba,
|
||||
|
||||
@@ -9,7 +9,7 @@ from tasks.utils import batched
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
|
||||
from api.models import Finding, Integration, Provider
|
||||
from api.models import Finding, Integration, JiraIssueDispatch, Provider
|
||||
from api.utils import initialize_prowler_integration, initialize_prowler_provider
|
||||
from prowler.lib.outputs.asff.asff import ASFF
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
@@ -482,66 +482,115 @@ def send_findings_to_jira(
|
||||
with rls_transaction(tenant_id):
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
jira_integration = initialize_prowler_integration(integration)
|
||||
# Idempotency: findings already ticketed for this integration must not be
|
||||
# sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues
|
||||
already_sent = {
|
||||
str(fid)
|
||||
for fid in JiraIssueDispatch.objects.filter(
|
||||
integration_id=integration_id, finding_id__in=finding_ids
|
||||
).values_list("finding_id", flat=True)
|
||||
}
|
||||
|
||||
num_tickets_created = 0
|
||||
skipped_count = 0
|
||||
for finding_id in finding_ids:
|
||||
if str(finding_id) in already_sent:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Reserve the finding BEFORE the external call. The unique constraint on
|
||||
# (tenant, integration, finding) makes the dispatch row the single source of
|
||||
# truth, so a concurrent run or a retry that raced past the bulk pre-check
|
||||
# cannot create a duplicate issue: created=False means another run already
|
||||
# claimed it. The reservation is released below if the send does not succeed.
|
||||
with rls_transaction(tenant_id):
|
||||
finding_instance = (
|
||||
Finding.all_objects.select_related("scan__provider")
|
||||
.prefetch_related("resources")
|
||||
.get(id=finding_id)
|
||||
_, created = JiraIssueDispatch.objects.get_or_create(
|
||||
tenant_id=tenant_id,
|
||||
integration_id=integration_id,
|
||||
finding_id=finding_id,
|
||||
)
|
||||
if not created:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Extract resource information
|
||||
resource = (
|
||||
finding_instance.resources.first()
|
||||
if finding_instance.resources.exists()
|
||||
else None
|
||||
)
|
||||
resource_uid = resource.uid if resource else ""
|
||||
resource_name = resource.name if resource else ""
|
||||
resource_tags = {}
|
||||
if resource and hasattr(resource, "tags"):
|
||||
resource_tags = resource.get_tags(tenant_id)
|
||||
sent = False
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
finding_instance = (
|
||||
Finding.all_objects.select_related("scan__provider")
|
||||
.prefetch_related("resources")
|
||||
.get(id=finding_id)
|
||||
)
|
||||
|
||||
# Get region
|
||||
region = resource.region if resource and resource.region else ""
|
||||
# Extract resource information
|
||||
resource = (
|
||||
finding_instance.resources.first()
|
||||
if finding_instance.resources.exists()
|
||||
else None
|
||||
)
|
||||
resource_uid = resource.uid if resource else ""
|
||||
resource_name = resource.name if resource else ""
|
||||
resource_tags = {}
|
||||
if resource and hasattr(resource, "tags"):
|
||||
resource_tags = resource.get_tags(tenant_id)
|
||||
|
||||
# Extract remediation information from check_metadata
|
||||
check_metadata = finding_instance.check_metadata
|
||||
remediation = check_metadata.get("remediation", {})
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
# Get region
|
||||
region = resource.region if resource and resource.region else ""
|
||||
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
|
||||
remediation_code_terraform=remediation_code.get("terraform", ""),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
else:
|
||||
logger.error(f"Failed to send finding {finding_id} to Jira")
|
||||
# Extract remediation information from check_metadata
|
||||
check_metadata = finding_instance.check_metadata
|
||||
remediation = check_metadata.get("remediation", {})
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
# Send the individual finding to Jira
|
||||
sent = bool(
|
||||
jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get(
|
||||
"nativeiac", ""
|
||||
),
|
||||
remediation_code_terraform=remediation_code.get(
|
||||
"terraform", ""
|
||||
),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if not sent:
|
||||
# Release the reservation so a later run can retry this finding: it
|
||||
# was not ticketed (send failed or raised), so the row must not block
|
||||
# a future legitimate send.
|
||||
with rls_transaction(tenant_id):
|
||||
JiraIssueDispatch.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
integration_id=integration_id,
|
||||
finding_id=finding_id,
|
||||
).delete()
|
||||
|
||||
if sent:
|
||||
num_tickets_created += 1
|
||||
else:
|
||||
logger.error(f"Failed to send finding {finding_id} to Jira")
|
||||
|
||||
return {
|
||||
"created_count": num_tickets_created,
|
||||
"failed_count": len(finding_ids) - num_tickets_created,
|
||||
"failed_count": len(finding_ids) - num_tickets_created - skipped_count,
|
||||
"skipped_count": skipped_count,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Detect and recover orphaned Celery tasks.
|
||||
|
||||
A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the
|
||||
worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan
|
||||
from a still-running task by pinging the worker recorded on its `TaskResult`:
|
||||
|
||||
- worker responds -> the task is in flight, leave it alone (never double-run);
|
||||
- worker is gone -> real orphan: mark the stale result terminal (so pending/started
|
||||
alerts clear), then re-enqueue the task from its stored name + kwargs.
|
||||
|
||||
This recovers only allowlisted tasks with local, proven idempotency. Celery's
|
||||
`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once
|
||||
the task starts, but external side-effect tasks are failed instead of blindly
|
||||
re-run. A small recovery cap stops a task that repeatedly kills its worker from
|
||||
looping forever.
|
||||
|
||||
This is the shared engine behind both the periodic Beat watchdog and the
|
||||
`reconcile_orphan_tasks` management command.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from celery import current_app, states
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.db import connections
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation
|
||||
# runs at a time across replicas / the watchdog / the command.
|
||||
ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow"
|
||||
|
||||
# Non-terminal states that mean "a worker had this and may have died with it".
|
||||
IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED)
|
||||
|
||||
# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row,
|
||||
# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would
|
||||
# hit its "a scan is already executing" guard and no-op, leaving the scan stuck.
|
||||
_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled")
|
||||
|
||||
# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and
|
||||
# rewrite their own rows. integration-jira is safe too: each finding is reserved in
|
||||
# JiraIssueDispatch before the external call, so a re-run skips already-ticketed
|
||||
# findings (worst case one finding missed on a mid-send crash, never a duplicate).
|
||||
# Other external side effects stay terminal: integration-s3 rebuilds its upload from
|
||||
# worker-local files that do not survive a crash, and report/Security Hub recovery is
|
||||
# out of scope.
|
||||
REENQUEUEABLE_TASKS = {
|
||||
*_SCAN_TASKS,
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
"scan-summary",
|
||||
"scan-compliance-overviews",
|
||||
"scan-provider-compliance-scores",
|
||||
"scan-daily-severity",
|
||||
"scan-finding-group-summaries",
|
||||
"scan-reset-ephemeral-resources",
|
||||
"integration-jira",
|
||||
}
|
||||
|
||||
# Tasks excluded from generic recovery: attack-paths scans are handled by their own
|
||||
# stale-cleanup (which also drops the temp Neo4j db), and the maintenance tasks must
|
||||
# not self-recover (they run again on their own schedule).
|
||||
_SKIP_RECOVERY = {
|
||||
"attack-paths-scan-perform",
|
||||
"attack-paths-cleanup-stale-scans",
|
||||
"reconcile-orphan-tasks",
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"):
|
||||
"""Yield True if this session won a Postgres advisory lock, else False.
|
||||
|
||||
Non-blocking: losers get False and should no-op. The lock is released on
|
||||
exit (and implicitly if the session dies).
|
||||
"""
|
||||
with connections[using].cursor() as cursor:
|
||||
cursor.execute("SELECT pg_try_advisory_lock(%s)", [key])
|
||||
acquired = bool(cursor.fetchone()[0])
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", [key])
|
||||
|
||||
|
||||
def is_worker_alive(worker: str, timeout: float = 1.0) -> bool:
|
||||
"""Ping a specific Celery worker. Returns True if it responds, or on error.
|
||||
|
||||
Erring on the side of "alive" means an unreachable control bus never causes
|
||||
a still-running task to be re-enqueued.
|
||||
"""
|
||||
try:
|
||||
response = current_app.control.inspect(
|
||||
destination=[worker], timeout=timeout
|
||||
).ping()
|
||||
return response is not None and worker in response
|
||||
except Exception:
|
||||
logger.exception(f"Failed to ping worker {worker}, treating as alive")
|
||||
return True
|
||||
|
||||
|
||||
def revoke_task(task_result, terminate: bool = True) -> None:
|
||||
"""Revoke a Celery task by its TaskResult. Non-fatal on failure.
|
||||
|
||||
terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False
|
||||
only marks the id revoked so any worker pulling the queued message discards it
|
||||
(use before re-enqueuing, so a later broker redelivery of the stale message is
|
||||
dropped).
|
||||
"""
|
||||
try:
|
||||
kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
|
||||
current_app.control.revoke(task_result.task_id, **kwargs)
|
||||
logger.info(f"Revoked task {task_result.task_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
|
||||
|
||||
def _decode_celery_field(value, default):
|
||||
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
|
||||
|
||||
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
|
||||
empty or missing field returns ``default``; a non-empty value that cannot be
|
||||
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
|
||||
the wrong arguments.
|
||||
"""
|
||||
obj = value
|
||||
for _ in range(2): # values can be double-encoded (a string holding a repr)
|
||||
if not isinstance(obj, str):
|
||||
break
|
||||
text = obj.strip()
|
||||
if not text:
|
||||
return default
|
||||
parsed = None
|
||||
for parser in (ast.literal_eval, json.loads):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
continue
|
||||
if parsed is None:
|
||||
raise ValueError(f"undecodable celery field: {text[:120]!r}")
|
||||
obj = parsed
|
||||
return default if obj is None else obj
|
||||
|
||||
|
||||
def reconcile_orphans(
|
||||
grace_minutes: int = 2,
|
||||
max_attempts: int = 3,
|
||||
window_hours: int = 6,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Run the full orphan sweep under a single-flight advisory lock.
|
||||
|
||||
Recovers any orphaned in-flight task and delegates attack-paths scans that
|
||||
never reached a worker to their existing stale-cleanup. Returns a summary;
|
||||
a no-op (lock not won) is reported too.
|
||||
"""
|
||||
with advisory_lock() as acquired:
|
||||
if not acquired:
|
||||
logger.info("Orphan reconcile skipped: another run holds the lock")
|
||||
return {"acquired": False}
|
||||
|
||||
# Populate the task registry so we can re-enqueue any task by name.
|
||||
import tasks.tasks # noqa: F401
|
||||
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=grace_minutes,
|
||||
max_attempts=max_attempts,
|
||||
window_hours=window_hours,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
result["attack_paths"] = cleanup_stale_attack_paths_scans()
|
||||
|
||||
return {"acquired": True, **result}
|
||||
|
||||
|
||||
def _reconcile_task_results(
|
||||
grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool
|
||||
) -> dict:
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=grace_minutes)
|
||||
candidates = list(
|
||||
TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff)
|
||||
.exclude(worker__isnull=True)
|
||||
.exclude(worker="")
|
||||
.exclude(task_name__in=_SKIP_RECOVERY)
|
||||
)
|
||||
|
||||
# Ping each distinct worker at most once.
|
||||
worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}}
|
||||
|
||||
recovered, failed, skipped = [], [], []
|
||||
for task_result in candidates:
|
||||
if worker_alive.get(task_result.worker, True):
|
||||
skipped.append(task_result.task_id) # in flight, do not double-run
|
||||
continue
|
||||
if dry_run:
|
||||
recovered.append(task_result.task_id)
|
||||
continue
|
||||
outcome = _recover_task(task_result, max_attempts, window_hours)
|
||||
(recovered if outcome == "recovered" else failed).append(task_result.task_id)
|
||||
|
||||
logger.info(
|
||||
"Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d",
|
||||
len(recovered),
|
||||
len(failed),
|
||||
len(skipped),
|
||||
)
|
||||
return {"recovered": recovered, "failed": failed, "skipped": skipped}
|
||||
|
||||
|
||||
def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int:
|
||||
"""Increment and return the recovery count for this (task, kwargs) within the
|
||||
window. Backed by Valkey so it survives result-row churn (a worker processing
|
||||
the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the
|
||||
broker being unreachable means nothing is running anyway).
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.from_url(settings.CELERY_BROKER_URL)
|
||||
signature = f"{name}|{kwargs_repr}".encode()
|
||||
key = (
|
||||
"orphan-recovery:"
|
||||
+ hashlib.sha1(signature, usedforsecurity=False).hexdigest()
|
||||
)
|
||||
count = client.incr(key)
|
||||
if count == 1:
|
||||
client.expire(key, max(1, window_hours) * 3600)
|
||||
return int(count)
|
||||
except Exception:
|
||||
logger.exception("Recovery-attempt counter unavailable; allowing recovery")
|
||||
return 1
|
||||
|
||||
|
||||
def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
|
||||
"""Recover one orphaned task. Returns 'recovered' or 'failed'."""
|
||||
# Capture name/args/kwargs now: revoking can let a worker blank the row.
|
||||
name = task_result.task_name
|
||||
args_repr = task_result.task_args
|
||||
kwargs_repr = task_result.task_kwargs
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Drop any future broker redelivery of the stale message.
|
||||
revoke_task(task_result, terminate=False)
|
||||
|
||||
# Mark the stale result terminal so "pending/started forever" alerts clear.
|
||||
task_result.status = states.REVOKED
|
||||
task_result.date_done = now
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
|
||||
if name not in REENQUEUEABLE_TASKS or attempt > max_attempts:
|
||||
reason = (
|
||||
f"{name} is not allowlisted for auto recovery"
|
||||
if name not in REENQUEUEABLE_TASKS
|
||||
else f"recovery cap reached ({attempt}/{max_attempts})"
|
||||
)
|
||||
_fail_domain_row(task_result.task_id, name, now)
|
||||
logger.warning(
|
||||
"Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason
|
||||
)
|
||||
return "failed"
|
||||
|
||||
# Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the
|
||||
# scheduled-scan "already executing" guard cannot turn recovery into a no-op.
|
||||
# Falls through to the generic path only if no scan is linked yet (e.g. a
|
||||
# scheduled task that died before creating one), where re-running it creates one.
|
||||
if name in _SCAN_TASKS:
|
||||
scan = _scan_for_task(task_result.task_id)
|
||||
if scan is not None:
|
||||
if not _reenqueue_scan(task_result.task_id, scan):
|
||||
return "failed"
|
||||
logger.info(
|
||||
"Re-enqueued orphaned scan %s (was task %s)",
|
||||
scan.id,
|
||||
task_result.task_id,
|
||||
)
|
||||
return "recovered"
|
||||
|
||||
task_obj = current_app.tasks.get(name)
|
||||
if task_obj is None:
|
||||
logger.error(
|
||||
"Orphan %s: task %s not registered, cannot re-enqueue",
|
||||
task_result.task_id,
|
||||
name,
|
||||
)
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
args = _decode_celery_field(args_repr, [])
|
||||
kwargs = _decode_celery_field(kwargs_repr, {})
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
|
||||
task_result.task_id,
|
||||
name,
|
||||
)
|
||||
_fail_domain_row(task_result.task_id, name, now)
|
||||
return "failed"
|
||||
new_task_id = str(uuid4())
|
||||
task_obj.apply_async(
|
||||
args=list(args) if isinstance(args, (list, tuple)) else [],
|
||||
kwargs=kwargs if isinstance(kwargs, dict) else {},
|
||||
task_id=new_task_id,
|
||||
)
|
||||
logger.info(
|
||||
"Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id
|
||||
)
|
||||
return "recovered"
|
||||
|
||||
|
||||
def _scan_for_task(task_id: str):
|
||||
"""Return the Scan linked to a Celery task id, or None (read across tenants)."""
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Scan
|
||||
|
||||
return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first()
|
||||
|
||||
|
||||
def _reenqueue_scan(old_task_id: str, scan) -> bool:
|
||||
"""Re-run an orphaned scan via scan-perform on the existing row.
|
||||
|
||||
Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the
|
||||
Scan before enqueuing, so the FK is valid and a worker can never outrun the DB.
|
||||
The relink is conditional on the scan still pointing at the old task, so a stale
|
||||
orphan can never clobber a newer linkage.
|
||||
"""
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Scan
|
||||
from api.models import Task as APITask
|
||||
from tasks.tasks import perform_scan_task
|
||||
|
||||
tenant_id = str(scan.tenant_id)
|
||||
new_task_id = str(uuid4())
|
||||
with rls_transaction(tenant_id):
|
||||
locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first()
|
||||
if locked_scan is None or str(locked_scan.task_id) != old_task_id:
|
||||
logger.info(
|
||||
"Scan %s no longer points at task %s; skipping recovery re-enqueue",
|
||||
scan.id,
|
||||
old_task_id,
|
||||
)
|
||||
return False
|
||||
task_result_new, _ = TaskResult.objects.get_or_create(
|
||||
task_id=new_task_id,
|
||||
defaults={"status": states.PENDING, "task_name": "scan-perform"},
|
||||
)
|
||||
APITask.objects.update_or_create(
|
||||
id=new_task_id,
|
||||
tenant_id=tenant_id,
|
||||
defaults={"task_runner_task": task_result_new},
|
||||
)
|
||||
locked_scan.task_id = new_task_id
|
||||
locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1
|
||||
locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"])
|
||||
|
||||
perform_scan_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": tenant_id,
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
},
|
||||
task_id=new_task_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None:
|
||||
"""Mark a scan terminal when its task is capped/denylisted instead of re-run."""
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Scan, StateChoices
|
||||
|
||||
if name in _SCAN_TASKS:
|
||||
scan = _scan_for_task(old_task_id)
|
||||
if scan is not None:
|
||||
with rls_transaction(str(scan.tenant_id)):
|
||||
Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update(
|
||||
state=StateChoices.FAILED, completed_at=now
|
||||
)
|
||||
@@ -29,7 +29,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
|
||||
from api.utils import initialize_prowler_provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
)
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
@@ -571,7 +574,7 @@ def generate_csa_report(
|
||||
Args:
|
||||
tenant_id: The tenant ID for Row-Level Security context.
|
||||
scan_id: ID of the scan executed by Prowler.
|
||||
compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws").
|
||||
compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0").
|
||||
output_path: Output PDF file path.
|
||||
provider_id: Provider ID for the scan.
|
||||
only_failed: If True, only include failed requirements in detailed section.
|
||||
@@ -883,9 +886,11 @@ def generate_compliance_reports(
|
||||
frameworks_bulk.get(f"nis2_{provider_type}")
|
||||
)
|
||||
if generate_csa:
|
||||
pending_checks_by_framework["csa"] = _get_compliance_check_ids(
|
||||
frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}")
|
||||
)
|
||||
# csa_ccm_4.0 lives at the top level, not under compliance/{provider}/.
|
||||
csa_framework = frameworks_bulk.get(
|
||||
"csa_ccm_4.0"
|
||||
) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0")
|
||||
pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework)
|
||||
if generate_cis and latest_cis:
|
||||
pending_checks_by_framework["cis"] = _get_compliance_check_ids(
|
||||
frameworks_bulk.get(latest_cis)
|
||||
@@ -1183,7 +1188,7 @@ def generate_compliance_reports(
|
||||
if generate_csa:
|
||||
generated_report_keys.append("csa")
|
||||
csa_path = output_paths["csa"]
|
||||
compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
|
||||
compliance_id_csa = "csa_ccm_4.0"
|
||||
pdf_path_csa = f"{csa_path}_csa_report.pdf"
|
||||
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
@@ -26,7 +27,10 @@ from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Provider, StatusChoices
|
||||
from api.utils import initialize_prowler_provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
)
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
from .components import (
|
||||
@@ -222,6 +226,46 @@ def get_requirement_metadata(
|
||||
return None
|
||||
|
||||
|
||||
def _universal_attributes_to_list(attributes) -> list:
|
||||
"""Flatten a universal requirement's ``attributes`` into a list of objects
|
||||
with attribute access. MITRE wraps its list under ``_raw_attributes``."""
|
||||
if isinstance(attributes, dict) and "_raw_attributes" in attributes:
|
||||
entries = attributes.get("_raw_attributes") or []
|
||||
return [
|
||||
SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict)
|
||||
]
|
||||
if isinstance(attributes, dict):
|
||||
return [SimpleNamespace(**attributes)] if attributes else []
|
||||
return list(attributes or [])
|
||||
|
||||
|
||||
def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace:
|
||||
"""Expose a universal ``ComplianceFramework`` under the legacy ``Compliance``
|
||||
attribute names used by the PDF pipeline."""
|
||||
provider_key = (provider_type or "").lower()
|
||||
requirements = []
|
||||
for requirement in framework.requirements:
|
||||
checks_by_provider = (
|
||||
requirement.checks if isinstance(requirement.checks, dict) else {}
|
||||
)
|
||||
requirements.append(
|
||||
SimpleNamespace(
|
||||
Id=requirement.id,
|
||||
Description=requirement.description or "",
|
||||
Checks=list(checks_by_provider.get(provider_key, [])),
|
||||
Attributes=_universal_attributes_to_list(requirement.attributes),
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(
|
||||
Framework=framework.framework,
|
||||
Name=framework.name,
|
||||
Version=framework.version or "",
|
||||
Description=framework.description or "",
|
||||
Provider=framework.provider or provider_type,
|
||||
Requirements=requirements,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PDF Styles Cache
|
||||
# =============================================================================
|
||||
@@ -869,9 +913,18 @@ class BaseComplianceReportGenerator(ABC):
|
||||
prowler_provider = initialize_prowler_provider(provider_obj)
|
||||
provider_type = provider_obj.provider
|
||||
|
||||
# Load compliance framework
|
||||
frameworks_bulk = Compliance.get_bulk(provider_type)
|
||||
compliance_obj = frameworks_bulk.get(compliance_id)
|
||||
# Load compliance framework — fall back to the universal loader
|
||||
# for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk
|
||||
# does not scan.
|
||||
compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id)
|
||||
if not compliance_obj:
|
||||
universal_framework = get_bulk_compliance_frameworks_universal(
|
||||
provider_type
|
||||
).get(compliance_id)
|
||||
if universal_framework:
|
||||
compliance_obj = _adapt_universal_to_legacy(
|
||||
universal_framework, provider_type
|
||||
)
|
||||
|
||||
if not compliance_obj:
|
||||
raise ValueError(f"Compliance framework not found: {compliance_id}")
|
||||
|
||||
@@ -118,6 +118,19 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
|
||||
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None:
|
||||
"""Remove rows derived from a previous execution of this scan."""
|
||||
with rls_transaction(tenant_id):
|
||||
Finding.all_objects.filter(scan_id=scan_id).delete()
|
||||
ResourceScanSummary.objects.filter(scan_id=scan_id).delete()
|
||||
ScanCategorySummary.objects.filter(scan_id=scan_id).delete()
|
||||
ScanGroupSummary.objects.filter(scan_id=scan_id).delete()
|
||||
ScanSummary.objects.filter(scan_id=scan_id).delete()
|
||||
AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete()
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
|
||||
|
||||
|
||||
def aggregate_category_counts(
|
||||
categories: list[str],
|
||||
severity: str,
|
||||
@@ -476,9 +489,13 @@ def _create_compliance_summaries(
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk insert summaries
|
||||
if summary_objects:
|
||||
with rls_transaction(tenant_id):
|
||||
# Idempotent re-run: clear this scan's prior summaries before re-inserting, so
|
||||
# a recovered scan's summary always reflects its own (re-derived) requirement
|
||||
# rows rather than keeping a stale row (bulk_create ignore_conflicts alone would
|
||||
# keep the old one).
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
|
||||
if summary_objects:
|
||||
ComplianceOverviewSummary.objects.bulk_create(
|
||||
summary_objects, batch_size=500, ignore_conflicts=True
|
||||
)
|
||||
@@ -1022,6 +1039,7 @@ def perform_prowler_scan(
|
||||
scan_instance.state = StateChoices.EXECUTING
|
||||
scan_instance.started_at = datetime.now(tz=timezone.utc)
|
||||
scan_instance.save(update_fields=["state", "started_at", "updated_at"])
|
||||
_clear_scan_rerun_state(tenant_id, scan_id)
|
||||
|
||||
# Find the mutelist processor if it exists
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
@@ -1651,6 +1669,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
elif requirement_status == "PASS":
|
||||
requirement_statuses[key]["pass_count"] += 1
|
||||
|
||||
# Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first.
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
|
||||
# Bulk create requirement records using PostgreSQL COPY
|
||||
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
|
||||
|
||||
|
||||
@@ -359,35 +359,40 @@ def _load_findings_for_requirement_checks(
|
||||
def _get_compliance_check_ids(compliance_obj) -> set[str]:
|
||||
"""Return the union of all check_ids referenced by a compliance framework.
|
||||
|
||||
Used by the master report orchestrator to know which checks each
|
||||
framework consumes from the shared ``findings_cache``, so that once a
|
||||
framework finishes the entries no other pending framework needs can be
|
||||
evicted from the cache (PROWLER-1733).
|
||||
Used by the master report orchestrator to evict entries from
|
||||
``findings_cache`` once no pending framework needs them (PROWLER-1733).
|
||||
|
||||
Args:
|
||||
compliance_obj: A loaded Compliance framework object exposing a
|
||||
``Requirements`` iterable, each requirement carrying ``Checks``.
|
||||
``None`` is treated as "no checks" rather than raising, so the
|
||||
caller can pass ``frameworks_bulk.get(...)`` directly without
|
||||
an extra existence check.
|
||||
|
||||
Returns:
|
||||
Set of check_id strings (empty if ``compliance_obj`` is ``None``).
|
||||
Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks``
|
||||
lists) and the universal ``ComplianceFramework`` shape (``requirements``
|
||||
/ ``checks`` dict keyed by provider). ``None`` returns an empty set so
|
||||
callers can pass ``frameworks_bulk.get(...)`` directly.
|
||||
"""
|
||||
if compliance_obj is None:
|
||||
return set()
|
||||
checks: set[str] = set()
|
||||
requirements = getattr(compliance_obj, "Requirements", None) or []
|
||||
|
||||
requirements = getattr(compliance_obj, "Requirements", None) or getattr(
|
||||
compliance_obj, "requirements", None
|
||||
)
|
||||
if not requirements:
|
||||
return set()
|
||||
|
||||
check_ids: set[str] = set()
|
||||
try:
|
||||
# Defensive: Mock objects (used in unit tests) return another Mock
|
||||
# for any attribute access, which is truthy but not iterable. Treat
|
||||
# any non-iterable Requirements value as "no checks".
|
||||
for req in requirements:
|
||||
req_checks = getattr(req, "Checks", None) or []
|
||||
# Mock objects in unit tests return another Mock for any attribute
|
||||
# access — truthy but not iterable. Treat that as "no checks".
|
||||
for requirement in requirements:
|
||||
requirement_checks = getattr(requirement, "Checks", None)
|
||||
if requirement_checks is None:
|
||||
checks_by_provider = getattr(requirement, "checks", None) or {}
|
||||
requirement_checks = [
|
||||
check_id
|
||||
for check_ids_list in checks_by_provider.values()
|
||||
for check_id in check_ids_list
|
||||
]
|
||||
try:
|
||||
checks.update(req_checks)
|
||||
check_ids.update(requirement_checks)
|
||||
except TypeError:
|
||||
continue
|
||||
except TypeError:
|
||||
return set()
|
||||
return checks
|
||||
return check_ids
|
||||
|
||||
@@ -46,6 +46,7 @@ from tasks.jobs.lighthouse_providers import (
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
_cleanup_stale_tmp_output_directories,
|
||||
@@ -67,7 +68,10 @@ from tasks.utils import (
|
||||
get_next_execution_datetime,
|
||||
)
|
||||
|
||||
from api.compliance import get_compliance_frameworks
|
||||
from api.compliance import (
|
||||
get_compliance_frameworks,
|
||||
get_prowler_provider_compliance,
|
||||
)
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import delete_related_daily_task, rls_transaction
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
@@ -75,6 +79,9 @@ from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateC
|
||||
from api.utils import initialize_prowler_provider
|
||||
from api.v1.serializers import ScanTaskSerializer
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.compliance.compliance import (
|
||||
process_universal_compliance_frameworks,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
@@ -462,13 +469,42 @@ def cleanup_stale_attack_paths_scans_task():
|
||||
return cleanup_stale_attack_paths_scans()
|
||||
|
||||
|
||||
@shared_task(name="reconcile-orphan-tasks", queue="celery")
|
||||
def reconcile_orphan_tasks_task():
|
||||
"""Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
|
||||
return reconcile_orphans()
|
||||
|
||||
|
||||
@shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,))
|
||||
def delete_tenant_task(tenant_id: str):
|
||||
return delete_tenant(pk=tenant_id)
|
||||
|
||||
|
||||
def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
|
||||
"""Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
|
||||
return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
|
||||
|
||||
|
||||
class ScanReportRLSTask(RLSTask):
|
||||
"""
|
||||
RLS task that removes the scan's tmp output directory when the task fails.
|
||||
|
||||
Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
|
||||
or setup errors) so partial artifacts do not accumulate on the worker disk.
|
||||
"""
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
|
||||
del args # Required by Celery's Task.on_failure signature; not used.
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Scan report task {task_id} failed: {exc}")
|
||||
rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask,
|
||||
base=ScanReportRLSTask,
|
||||
name="scan-report",
|
||||
queue="scan-reports",
|
||||
)
|
||||
@@ -513,11 +549,23 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
provider_uid = provider_obj.uid
|
||||
provider_type = provider_obj.provider
|
||||
|
||||
# Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk.
|
||||
frameworks_bulk = Compliance.get_bulk(provider_type)
|
||||
# Universal-only frameworks (top-level JSONs like `dora.json`) are emitted
|
||||
# via `process_universal_compliance_frameworks` below.
|
||||
universal_bulk = get_prowler_provider_compliance(provider_type)
|
||||
universal_only_names = {
|
||||
name
|
||||
for name in universal_bulk
|
||||
if name not in frameworks_bulk and universal_bulk[name].outputs
|
||||
}
|
||||
frameworks_avail = get_compliance_frameworks(provider_type)
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
@@ -535,6 +583,10 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
|
||||
output_writers = {}
|
||||
compliance_writers = {}
|
||||
# Shared across batches so universal writers are created once and reused.
|
||||
universal_compliance_state: dict[str, list] = {"compliance": []}
|
||||
universal_base_dir = os.path.dirname(out_dir)
|
||||
universal_output_filename = os.path.basename(out_dir)
|
||||
|
||||
scan_summary = FindingOutput._transform_findings_stats(
|
||||
ScanSummary.objects.filter(scan_id=scan_id)
|
||||
@@ -589,8 +641,30 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
writer.batch_write_data_to_file(**extra)
|
||||
writer._data.clear()
|
||||
|
||||
# Compliance CSVs
|
||||
# Universal-only frameworks (e.g. `dora.json`).
|
||||
if universal_only_names:
|
||||
process_universal_compliance_frameworks(
|
||||
input_compliance_frameworks=universal_only_names,
|
||||
universal_frameworks=universal_bulk,
|
||||
finding_outputs=fos,
|
||||
output_directory=universal_base_dir,
|
||||
output_filename=universal_output_filename,
|
||||
provider=provider_type,
|
||||
generated_outputs=universal_compliance_state,
|
||||
from_cli=False,
|
||||
is_last=is_last,
|
||||
)
|
||||
|
||||
# Compliance CSVs (per-framework exporters).
|
||||
for name in frameworks_avail:
|
||||
if name in universal_only_names:
|
||||
continue
|
||||
if name not in frameworks_bulk:
|
||||
logger.warning(
|
||||
"Compliance framework '%s' missing from bulk; skipping CSV export",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
compliance_obj = frameworks_bulk[name]
|
||||
|
||||
klass = GenericCompliance
|
||||
@@ -666,7 +740,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
# TODO: We need to create a new periodic task to delete the output files
|
||||
# This task shouldn't be responsible for deleting the output files
|
||||
try:
|
||||
rmtree(Path(compressed).parent, ignore_errors=True)
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting output files: {e}")
|
||||
final_location, did_upload = upload_uri, True
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from unittest.mock import call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant, TenantComplianceSummary
|
||||
from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -34,6 +35,43 @@ class TestDeleteProvider:
|
||||
str(instance.id),
|
||||
)
|
||||
|
||||
def test_delete_provider_removes_jira_dispatches(
|
||||
self,
|
||||
providers_fixture,
|
||||
findings_fixture,
|
||||
integrations_fixture,
|
||||
):
|
||||
"""Deleting a provider removes JiraIssueDispatch rows for its findings only."""
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
integration = integrations_fixture[0]
|
||||
|
||||
# Dispatch for one of the provider's findings: must be removed with it.
|
||||
JiraIssueDispatch.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=integration,
|
||||
finding_id=finding.id,
|
||||
)
|
||||
# Dispatch for an unrelated finding: must survive the provider deletion.
|
||||
unrelated = JiraIssueDispatch.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=integration,
|
||||
finding_id=uuid4(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
|
||||
):
|
||||
delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists()
|
||||
assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists()
|
||||
|
||||
def test_delete_provider_does_not_exist(self, tenants_fixture):
|
||||
with (
|
||||
patch(
|
||||
|
||||
@@ -1640,14 +1640,74 @@ class TestJiraIntegration:
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_skips_already_dispatched(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""A re-run skips findings already ticketed (no duplicate Jira issues)."""
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = MagicMock()
|
||||
# finding-1 was already dispatched in a prior run; finding-2 is new.
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [
|
||||
"finding-1"
|
||||
]
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
mock_jira_integration.send_finding.return_value = True
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding2 = MagicMock()
|
||||
finding2.id = "finding-2"
|
||||
finding2.check_id = "check_002"
|
||||
finding2.severity = "low"
|
||||
finding2.status = "FAIL"
|
||||
finding2.status_extended = ""
|
||||
finding2.compliance = {}
|
||||
finding2.resources.exists.return_value = False
|
||||
finding2.resources.first.return_value = None
|
||||
finding2.scan.provider.provider = "aws"
|
||||
finding2.check_metadata = {
|
||||
"checktitle": "C2",
|
||||
"risk": "",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2
|
||||
|
||||
result = send_findings_to_jira(
|
||||
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"]
|
||||
)
|
||||
|
||||
# finding-1 skipped (already sent); only finding-2 sent -> no duplicate.
|
||||
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1}
|
||||
mock_jira_integration.send_finding.assert_called_once()
|
||||
assert (
|
||||
mock_jira_integration.send_finding.call_args.kwargs["check_id"]
|
||||
== "check_002"
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_success(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test successful sending of findings to Jira using send_finding method"""
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
@@ -1739,7 +1799,7 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 0}
|
||||
assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0}
|
||||
|
||||
# Verify Jira integration was initialized
|
||||
mock_initialize_integration.assert_called_once_with(integration)
|
||||
@@ -1771,8 +1831,10 @@ class TestJiraIntegration:
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_partial_failure(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
@@ -1780,6 +1842,8 @@ class TestJiraIntegration:
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test partial failure when sending findings to Jira"""
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
@@ -1833,23 +1897,35 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 1}
|
||||
assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0}
|
||||
|
||||
# Verify error was logged for the failed finding
|
||||
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
|
||||
|
||||
# The failed finding's reservation is released so a later run can retry it.
|
||||
mock_jira_dispatch.objects.filter.assert_any_call(
|
||||
tenant_id=tenant_id,
|
||||
integration_id=integration_id,
|
||||
finding_id="finding-2",
|
||||
)
|
||||
mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once()
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_no_resources(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test sending findings to Jira when finding has no resources"""
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
@@ -1907,7 +1983,7 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 1, "failed_count": 0}
|
||||
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
|
||||
|
||||
# Verify send_finding was called with empty resource fields
|
||||
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
|
||||
@@ -1920,14 +1996,18 @@ class TestJiraIntegration:
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_with_empty_check_metadata(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test sending findings to Jira when check_metadata is empty or missing fields"""
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
@@ -1970,7 +2050,7 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 1, "failed_count": 0}
|
||||
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
|
||||
|
||||
# Verify send_finding was called with default/empty values
|
||||
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
|
||||
@@ -1983,3 +2063,94 @@ class TestJiraIntegration:
|
||||
assert call_kwargs["remediation_code_cli"] == ""
|
||||
assert call_kwargs["remediation_code_other"] == ""
|
||||
assert call_kwargs["compliance"] == {}
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_reserves_before_sending(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""The dispatch row is reserved before the external Jira call (reserve-then-act)."""
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = MagicMock()
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
|
||||
order = []
|
||||
mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: (
|
||||
order.append(("reserve", kw)) or (MagicMock(), True)
|
||||
)
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
mock_jira_integration.send_finding.side_effect = lambda **kw: (
|
||||
order.append(("send", kw)) or True
|
||||
)
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "low"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = ""
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "C1",
|
||||
"risk": "",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
|
||||
)
|
||||
|
||||
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
|
||||
# Reservation must precede the external send.
|
||||
assert [entry[0] for entry in order] == ["reserve", "send"]
|
||||
# A successful send keeps the reservation (no rollback delete).
|
||||
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.JiraIssueDispatch")
|
||||
def test_send_findings_to_jira_skips_when_already_reserved(
|
||||
self,
|
||||
mock_jira_dispatch,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""A finding that races past the bulk pre-check but loses the reservation
|
||||
(created=False) is skipped without a second issue, leaving the row intact."""
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = MagicMock()
|
||||
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
|
||||
# Another concurrent run already created the dispatch row.
|
||||
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False)
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
result = send_findings_to_jira(
|
||||
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
|
||||
)
|
||||
|
||||
assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1}
|
||||
mock_jira_integration.send_finding.assert_not_called()
|
||||
# The reservation belongs to the run that won the race; do not delete it.
|
||||
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from celery import states
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
from api.models import Scan, StateChoices
|
||||
from api.models import Task as APITask
|
||||
from tasks.jobs.orphan_recovery import (
|
||||
_decode_celery_field,
|
||||
_reconcile_task_results,
|
||||
_recovery_attempt_count,
|
||||
_reenqueue_scan,
|
||||
advisory_lock,
|
||||
is_worker_alive,
|
||||
)
|
||||
|
||||
|
||||
def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED):
|
||||
"""Create a TaskResult mimicking an in-flight task, backdated past the grace."""
|
||||
tr = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
status=status,
|
||||
task_name=name,
|
||||
worker=worker,
|
||||
task_kwargs=repr(kwargs),
|
||||
task_args=repr([]),
|
||||
)
|
||||
TaskResult.objects.filter(pk=tr.pk).update(
|
||||
date_created=datetime.now(tz=timezone.utc)
|
||||
- timedelta(minutes=created_minutes_ago)
|
||||
)
|
||||
tr.refresh_from_db()
|
||||
return tr
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDecodeCeleryField:
|
||||
def test_decodes_single_encoded_repr(self):
|
||||
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
|
||||
|
||||
def test_decodes_double_encoded(self):
|
||||
import json
|
||||
|
||||
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
|
||||
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
|
||||
|
||||
def test_empty_returns_default(self):
|
||||
assert _decode_celery_field(None, {}) == {}
|
||||
assert _decode_celery_field("", []) == []
|
||||
|
||||
def test_unparseable_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_decode_celery_field("<<not a literal>>", {})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReconcileTaskResults:
|
||||
def _patches(self, alive):
|
||||
"""Patch worker liveness, revoke, and the task registry for re-enqueue."""
|
||||
mock_app = MagicMock()
|
||||
mock_task = MagicMock()
|
||||
mock_app.tasks.get.return_value = mock_task
|
||||
return (
|
||||
patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive),
|
||||
patch("tasks.jobs.orphan_recovery.revoke_task"),
|
||||
patch("tasks.jobs.orphan_recovery.current_app", mock_app),
|
||||
mock_task,
|
||||
)
|
||||
|
||||
def test_recovers_non_scan_task(self, tenants_fixture):
|
||||
"""A NON-scan task (tenant-deletion) left orphaned is re-enqueued too."""
|
||||
tenant = tenants_fixture[0]
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenant.id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["recovered"]
|
||||
tr.refresh_from_db()
|
||||
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
|
||||
mock_task.apply_async.assert_called_once()
|
||||
call = mock_task.apply_async.call_args.kwargs
|
||||
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
|
||||
assert call["task_id"] != tr.task_id # fresh task id
|
||||
|
||||
def test_external_integration_task_is_not_reenqueued_by_default(
|
||||
self, tenants_fixture
|
||||
):
|
||||
"""External side-effect tasks without proven idempotency stay terminal.
|
||||
|
||||
integration-s3 rebuilds its upload from worker-local files that do not
|
||||
survive the crash, so re-enqueuing it would upload nothing.
|
||||
"""
|
||||
tr = _orphan_result(
|
||||
name="integration-s3",
|
||||
kwargs={
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
"provider_id": str(uuid4()),
|
||||
"output_directory": "/tmp/gone",
|
||||
},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_jira_integration_task_is_reenqueued(self, tenants_fixture):
|
||||
"""integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a
|
||||
re-run skip already-ticketed findings, so recovery cannot duplicate issues."""
|
||||
tenant = tenants_fixture[0]
|
||||
kwargs = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"integration_id": str(uuid4()),
|
||||
"project_key": "PROWLER",
|
||||
"issue_type": "Task",
|
||||
"finding_ids": [str(uuid4()), str(uuid4())],
|
||||
}
|
||||
tr = _orphan_result(
|
||||
name="integration-jira",
|
||||
kwargs=kwargs,
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["recovered"]
|
||||
tr.refresh_from_db()
|
||||
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
|
||||
mock_task.apply_async.assert_called_once()
|
||||
call = mock_task.apply_async.call_args.kwargs
|
||||
assert call["kwargs"] == kwargs
|
||||
assert call["task_id"] != tr.task_id # fresh task id
|
||||
|
||||
def test_skips_live_worker(self, tenants_fixture):
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="alive@host",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=True)
|
||||
with p_alive, p_revoke, p_app:
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["skipped"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_skips_recently_created(self, tenants_fixture):
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=0,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with p_alive, p_revoke, p_app:
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
# too recent: excluded by the grace window (not even a candidate)
|
||||
assert tr.task_id not in result["recovered"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture):
|
||||
"""A non-allowlisted task is failed, never blind re-run."""
|
||||
tr = _orphan_result(
|
||||
name="some-non-idempotent-task",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
tr.refresh_from_db()
|
||||
assert tr.status == states.REVOKED
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_recovery_cap_marks_failed(self, tenants_fixture):
|
||||
"""When the recovery counter exceeds the cap, the task is failed not re-run."""
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanRecovery:
|
||||
"""Scans are recovered by re-running scan-perform on the EXISTING scan row,
|
||||
so even a scheduled-scan orphan (whose own task would no-op on its guard) is
|
||||
actually re-executed."""
|
||||
|
||||
def _scan_orphan(self, tenant, provider, name):
|
||||
old_id = str(uuid4())
|
||||
tr = TaskResult.objects.create(
|
||||
task_id=old_id,
|
||||
status=states.STARTED,
|
||||
task_name=name,
|
||||
worker="dead@gone",
|
||||
task_kwargs=repr(
|
||||
{"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
|
||||
),
|
||||
task_args=repr([]),
|
||||
)
|
||||
TaskResult.objects.filter(pk=tr.pk).update(
|
||||
date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60)
|
||||
)
|
||||
APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr)
|
||||
scan = Scan.objects.create(
|
||||
name="scan-orphan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.EXECUTING,
|
||||
tenant_id=tenant.id,
|
||||
task_id=old_id,
|
||||
recovery_count=0,
|
||||
)
|
||||
return old_id, scan
|
||||
|
||||
@pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"])
|
||||
def test_scan_recovered_via_scan_perform(
|
||||
self, tenants_fixture, providers_fixture, name
|
||||
):
|
||||
tenant, provider = tenants_fixture[0], providers_fixture[0]
|
||||
old_id, scan = self._scan_orphan(tenant, provider, name)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False),
|
||||
patch("tasks.jobs.orphan_recovery.revoke_task"),
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
patch("tasks.tasks.perform_scan_task") as mock_scan_task,
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert old_id in result["recovered"]
|
||||
scan.refresh_from_db()
|
||||
assert str(scan.task_id) != old_id # relinked to a fresh task
|
||||
assert scan.recovery_count == 1
|
||||
assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED
|
||||
# Recovered by re-running scan-perform on the existing scan row (so the
|
||||
# scheduled guard cannot no-op it), regardless of the original task name.
|
||||
mock_scan_task.apply_async.assert_called_once()
|
||||
assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(
|
||||
scan.id
|
||||
)
|
||||
|
||||
def test_reenqueue_skips_when_scan_already_repointed(
|
||||
self, tenants_fixture, providers_fixture
|
||||
):
|
||||
# The scan already points at a newer task, so a stale orphan must not relink
|
||||
# it or launch a second concurrent run against the same scan row.
|
||||
tenant, provider = tenants_fixture[0], providers_fixture[0]
|
||||
newer_id = str(uuid4())
|
||||
tr = TaskResult.objects.create(
|
||||
task_id=newer_id, status=states.STARTED, task_name="scan-perform"
|
||||
)
|
||||
APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr)
|
||||
scan = Scan.objects.create(
|
||||
name="scan-orphan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.EXECUTING,
|
||||
tenant_id=tenant.id,
|
||||
task_id=newer_id,
|
||||
recovery_count=0,
|
||||
)
|
||||
|
||||
with patch("tasks.tasks.perform_scan_task") as mock_scan_task:
|
||||
recovered = _reenqueue_scan(str(uuid4()), scan)
|
||||
|
||||
assert recovered is False
|
||||
mock_scan_task.apply_async.assert_not_called()
|
||||
scan.refresh_from_db()
|
||||
assert scan.recovery_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestOrphanRecoveryHelpers:
|
||||
def test_advisory_lock_acquires_and_releases(self):
|
||||
with advisory_lock() as acquired:
|
||||
assert acquired is True
|
||||
|
||||
def test_is_worker_alive_true_when_responds(self):
|
||||
inspect = MagicMock()
|
||||
inspect.ping.return_value = {"w@h": {"ok": "pong"}}
|
||||
with patch(
|
||||
"tasks.jobs.orphan_recovery.current_app.control.inspect",
|
||||
return_value=inspect,
|
||||
):
|
||||
assert is_worker_alive("w@h") is True
|
||||
|
||||
def test_is_worker_alive_false_when_silent(self):
|
||||
inspect = MagicMock()
|
||||
inspect.ping.return_value = None
|
||||
with patch(
|
||||
"tasks.jobs.orphan_recovery.current_app.control.inspect",
|
||||
return_value=inspect,
|
||||
):
|
||||
assert is_worker_alive("w@h") is False
|
||||
|
||||
def test_recovery_attempt_count_increments(self):
|
||||
# Unique signature so the Valkey counter starts fresh for this test.
|
||||
kwargs_repr = repr({"probe": str(uuid4())})
|
||||
redis_client = MagicMock()
|
||||
redis_client.incr.side_effect = [1, 2]
|
||||
with patch("redis.from_url", return_value=redis_client):
|
||||
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1
|
||||
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2
|
||||
@@ -80,7 +80,7 @@ def basic_csa_compliance_data():
|
||||
tenant_id="tenant-123",
|
||||
scan_id="scan-456",
|
||||
provider_id="provider-789",
|
||||
compliance_id="csa_ccm_4.0_aws",
|
||||
compliance_id="csa_ccm_4.0",
|
||||
framework="CSA-CCM",
|
||||
name="CSA Cloud Controls Matrix v4.0",
|
||||
version="4.0",
|
||||
|
||||
@@ -32,12 +32,15 @@ from tasks.utils import CustomEncoder
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ProviderConnectionError
|
||||
from api.models import (
|
||||
AttackSurfaceOverview,
|
||||
Finding,
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceScanSummary,
|
||||
Scan,
|
||||
ScanCategorySummary,
|
||||
ScanGroupSummary,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
@@ -229,6 +232,131 @@ class TestPerformScan:
|
||||
# Assert that failed_findings_count is 0 (finding is PASS and muted)
|
||||
assert scan_resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_idempotent_on_rerun(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Re-running a scan for the same scan_id must not duplicate findings."""
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
|
||||
new_callable=dict,
|
||||
),
|
||||
patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks,
|
||||
):
|
||||
mock_checks["aws"] = {"check1": {"compliance1"}}
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
tenant_id = str(tenant.id)
|
||||
scan_id = str(scan.id)
|
||||
provider_id = str(provider.id)
|
||||
|
||||
stale_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid="stale_resource_uid",
|
||||
name="stale",
|
||||
region="stale-region",
|
||||
service="stale-service",
|
||||
type="stale-type",
|
||||
)
|
||||
ResourceScanSummary.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
resource_id=stale_resource.id,
|
||||
service="stale-service",
|
||||
region="stale-region",
|
||||
resource_type="stale-type",
|
||||
)
|
||||
ScanCategorySummary.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
category="stale-category",
|
||||
severity=Severity.medium,
|
||||
total_findings=1,
|
||||
)
|
||||
ScanGroupSummary.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
resource_group="stale-group",
|
||||
severity=Severity.medium,
|
||||
total_findings=1,
|
||||
)
|
||||
ScanSummary.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
check_id="stale_check",
|
||||
service="stale-service",
|
||||
severity=Severity.medium,
|
||||
region="stale-region",
|
||||
total=1,
|
||||
)
|
||||
AttackSurfaceOverview.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
|
||||
total_findings=1,
|
||||
)
|
||||
|
||||
finding = MagicMock()
|
||||
finding.uid = "dup_probe_finding"
|
||||
finding.status = StatusChoices.PASS
|
||||
finding.status_extended = "x"
|
||||
finding.severity = Severity.medium
|
||||
finding.check_id = "check1"
|
||||
finding.get_metadata.return_value = {"key": "value"}
|
||||
finding.resource_uid = "resource_uid"
|
||||
finding.resource_name = "resource_name"
|
||||
finding.region = "region"
|
||||
finding.service_name = "service_name"
|
||||
finding.resource_type = "resource_type"
|
||||
finding.resource_tags = {}
|
||||
finding.muted = False
|
||||
finding.raw = {}
|
||||
finding.resource_metadata = {}
|
||||
finding.resource_details = {}
|
||||
finding.partition = "partition"
|
||||
finding.compliance = {}
|
||||
|
||||
mock_scan_instance = MagicMock()
|
||||
mock_scan_instance.scan.return_value = [(100, [finding])]
|
||||
mock_prowler_scan_class.return_value = mock_scan_instance
|
||||
|
||||
mock_provider_instance = MagicMock()
|
||||
mock_provider_instance.get_regions.return_value = ["region"]
|
||||
mock_initialize_prowler_provider.return_value = mock_provider_instance
|
||||
|
||||
# Run the same scan twice (simulating an orphan-recovery re-run).
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
|
||||
|
||||
# Neither findings nor resources are duplicated by the re-run: findings are
|
||||
# scope-deleted before re-insert; resources are upserted by (tenant, provider, uid).
|
||||
assert Finding.objects.filter(scan=scan).count() == 1
|
||||
assert Resource.objects.filter(provider=provider).count() == 2
|
||||
assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1
|
||||
assert not ResourceScanSummary.objects.filter(
|
||||
scan_id=scan.id, resource_id=stale_resource.id
|
||||
).exists()
|
||||
assert not ScanCategorySummary.objects.filter(scan=scan).exists()
|
||||
assert not ScanGroupSummary.objects.filter(scan=scan).exists()
|
||||
assert not ScanSummary.objects.filter(
|
||||
scan=scan, check_id="stale_check"
|
||||
).exists()
|
||||
assert not AttackSurfaceOverview.objects.filter(scan=scan).exists()
|
||||
|
||||
@patch("tasks.jobs.scan.ProwlerScan")
|
||||
@patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider",
|
||||
@@ -1880,6 +2008,62 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
assert "requirements_created" in result
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_create_compliance_requirements_idempotent_on_rerun(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
findings_fixture,
|
||||
):
|
||||
"""Re-running compliance materialization must not raise nor duplicate rows.
|
||||
|
||||
Uses transaction=True because the COPY path commits on its own connection,
|
||||
so the test must use real commits (mirroring production) rather than the
|
||||
default rollback wrapper.
|
||||
"""
|
||||
from api.models import ComplianceRequirementOverview
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template:
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
scan_id = str(scans_fixture[0].id)
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"test_compliance": {
|
||||
"framework": "Test Framework",
|
||||
"version": "1.0",
|
||||
"requirements": {
|
||||
"req_1": {
|
||||
"description": "Test Requirement 1",
|
||||
"checks": {"test_check_id": None},
|
||||
"checks_status": {
|
||||
"pass": 2,
|
||||
"fail": 1,
|
||||
"manual": 0,
|
||||
"total": 3,
|
||||
},
|
||||
"status": "FAIL",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
create_compliance_requirements(tenant_id, scan_id)
|
||||
count_after_first = ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).count()
|
||||
|
||||
# Second run must not raise and must not duplicate rows.
|
||||
create_compliance_requirements(tenant_id, scan_id)
|
||||
count_after_second = ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).count()
|
||||
|
||||
assert count_after_first > 0
|
||||
assert count_after_second == count_after_first
|
||||
|
||||
def test_create_compliance_requirements_kubernetes_provider(
|
||||
self,
|
||||
tenants_fixture,
|
||||
|
||||
@@ -15,8 +15,10 @@ from tasks.jobs.lighthouse_providers import (
|
||||
from tasks.tasks import (
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
ScanReportRLSTask,
|
||||
_cleanup_orphan_scheduled_scans,
|
||||
_perform_scan_complete_tasks,
|
||||
_scan_tmp_output_directory,
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
@@ -321,6 +323,7 @@ class TestGenerateOutputs:
|
||||
|
||||
mock_transformed_stats = {"some": "stats"}
|
||||
with (
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch(
|
||||
"tasks.tasks.FindingOutput._transform_findings_stats",
|
||||
return_value=mock_transformed_stats,
|
||||
@@ -439,6 +442,7 @@ class TestGenerateOutputs:
|
||||
mock_provider.uid = "test-provider-uid"
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
@@ -594,6 +598,7 @@ class TestGenerateOutputs:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary,
|
||||
patch(
|
||||
"tasks.tasks.Provider.objects.get",
|
||||
@@ -668,6 +673,7 @@ class TestGenerateOutputs:
|
||||
mock_provider.uid = "test-provider-uid"
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
@@ -771,6 +777,38 @@ class TestGenerateOutputs:
|
||||
mock_s3_task.assert_called_once()
|
||||
|
||||
|
||||
class TestScanReportRLSTaskOnFailure:
|
||||
def test_on_failure_removes_scan_tmp_directory(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_called_once_with(
|
||||
_scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
|
||||
)
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_not_called()
|
||||
|
||||
|
||||
class TestScanCompleteTasks:
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
|
||||
@patch("tasks.tasks.chain")
|
||||
@@ -1079,6 +1117,7 @@ class TestCheckIntegrationsTask:
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
|
||||
@patch("tasks.tasks.s3_integration_task")
|
||||
@patch("tasks.tasks.Integration.objects.filter")
|
||||
@patch("tasks.tasks.ScanSummary.objects.filter")
|
||||
@@ -1111,6 +1150,7 @@ class TestCheckIntegrationsTask:
|
||||
mock_scan_summary,
|
||||
mock_integration_filter,
|
||||
mock_s3_task,
|
||||
mock_get_prowler_compliance,
|
||||
):
|
||||
"""Test that ASFF output is generated for AWS providers with SecurityHub integration."""
|
||||
# Setup
|
||||
@@ -1207,6 +1247,7 @@ class TestCheckIntegrationsTask:
|
||||
|
||||
assert result == {"upload": True}
|
||||
|
||||
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
|
||||
@patch("tasks.tasks.s3_integration_task")
|
||||
@patch("tasks.tasks.Integration.objects.filter")
|
||||
@patch("tasks.tasks.ScanSummary.objects.filter")
|
||||
@@ -1239,6 +1280,7 @@ class TestCheckIntegrationsTask:
|
||||
mock_scan_summary,
|
||||
mock_integration_filter,
|
||||
mock_s3_task,
|
||||
mock_get_prowler_compliance,
|
||||
):
|
||||
"""Test that ASFF output is NOT generated for AWS providers without SecurityHub integration."""
|
||||
# Setup
|
||||
@@ -1332,6 +1374,7 @@ class TestCheckIntegrationsTask:
|
||||
|
||||
assert result == {"upload": True}
|
||||
|
||||
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
|
||||
@patch("tasks.tasks.ScanSummary.objects.filter")
|
||||
@patch("tasks.tasks.Provider.objects.get")
|
||||
@patch("tasks.tasks.initialize_prowler_provider")
|
||||
@@ -1360,6 +1403,7 @@ class TestCheckIntegrationsTask:
|
||||
mock_initialize_provider,
|
||||
mock_provider_get,
|
||||
mock_scan_summary,
|
||||
mock_get_prowler_compliance,
|
||||
):
|
||||
"""Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP)."""
|
||||
# Setup
|
||||
@@ -2672,3 +2716,36 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
|
||||
class TestTaskTimeLimits:
|
||||
"""The per-task limits in task_annotations must actually take effect.
|
||||
|
||||
Celery applies a "*" annotation after the per-task one, so a "*" entry would
|
||||
silently overwrite every specific limit and cap long scans at the default. The
|
||||
default is set as the global limit instead, and these per-task limits must win.
|
||||
"""
|
||||
|
||||
def test_long_running_tasks_exceed_the_default_limit(self):
|
||||
from config.celery import celery_app
|
||||
|
||||
default = celery_app.conf.task_time_limit
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
):
|
||||
assert celery_app.tasks[name].time_limit > default
|
||||
|
||||
def test_connection_checks_stay_below_the_default_limit(self):
|
||||
from config.celery import celery_app
|
||||
|
||||
default = celery_app.conf.task_time_limit
|
||||
for name in (
|
||||
"provider-connection-check",
|
||||
"integration-connection-check",
|
||||
"lighthouse-connection-check",
|
||||
"lighthouse-provider-connection-check",
|
||||
):
|
||||
assert celery_app.tasks[name].time_limit < default
|
||||
|
||||
Reference in New Issue
Block a user