Compare commits

...
Author SHA1 Message Date
Josema Camacho 21117d653e chore(api): remove generated API spec changes 2026-09-02 14:40:44 +02:00
Josema Camacho 1db76a43b8 fix(api): allow forced retry of stale Jira deliveries 2026-09-02 14:35:44 +02:00
Josema Camacho 5419b4b31b docs(api): clarify Jira skipped result outcomes 2026-09-02 14:02:30 +02:00
Josema Camacho 79640c930d fix(api): report concurrent Jira deliveries as deferred 2026-09-02 14:02:30 +02:00
Josema Camacho 743a63b64e fix(api): address Jira dedup review feedback 2026-09-02 14:02:30 +02:00
Josema Camacho ba0d1d42cd fix(api): address Jira delivery review feedback 2026-09-02 14:02:30 +02:00
Josema Camacho 621f22fc05 fix(api): address Jira issue review feedback 2026-09-02 14:02:30 +02:00
Josema Camacho 11e5ab5bb7 refactor(api): simplify Jira finding deduplication 2026-09-02 14:02:30 +02:00
Josema Camacho 25ab6de327 fix(api): harden Jira delivery recovery edge cases 2026-09-02 14:02:30 +02:00
Josema Camacho 99df95297b feat(api): add Jira issue recovery API 2026-09-02 14:02:30 +02:00
Josema Camacho 43de3790d2 feat(api): make Jira finding delivery retry-safe 2026-09-02 14:02:30 +02:00
Josema Camacho 2f9b5269de feat(api): harden Jira issue delivery ledger 2026-09-02 14:02:30 +02:00
Josema Camacho fe11de0e06 feat(api): track Jira issues per finding 2026-09-02 14:02:30 +02:00
f013a5e1ad chore(changelog): v5.41.0 highlights (#12709)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-09-02 13:51:45 +02:00
Prowler Botandprowler-bot 18453e592e chore(changelog): v5.41.0 (#12707)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-02 11:23:18 +02:00
70 changed files with 2918 additions and 187 deletions
+12
View File
@@ -4,6 +4,18 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.42.0] (Prowler v5.41.0)
### 🚀 Added
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
### 🐞 Fixed
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
---
## [1.41.0] (Prowler v5.40.0)
### 🐞 Fixed
@@ -1 +0,0 @@
Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name
@@ -0,0 +1 @@
Finding-to-Jira issue tracking across scans, concurrent duplicate prevention, completed-issue replacement, and confirmed links through GET /api/v1/jira-issues
@@ -1 +0,0 @@
`POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues
+28
View File
@@ -17,6 +17,7 @@ from api.models import (
FindingGroupDailySummary,
Integration,
Invitation,
JiraIssue,
LighthouseProviderConfiguration,
LighthouseProviderModels,
Membership,
@@ -1900,3 +1901,30 @@ class ComplianceWatchlistFilter(BaseProviderFilter):
class Meta(BaseProviderFilter.Meta):
model = ProviderComplianceScore
class JiraIssueFilter(BaseProviderFilter):
finding_uid = CharFilter(field_name="finding_uid", lookup_expr="exact")
finding_uid__in = CharInFilter(field_name="finding_uid", lookup_expr="in")
finding_id = UUIDFilter(field_name="finding_id", lookup_expr="exact")
finding_id__in = UUIDInFilter(field_name="finding_id", lookup_expr="in")
integration = UUIDFilter(field_name="integration__id", lookup_expr="exact")
integration__in = UUIDInFilter(field_name="integration__id", lookup_expr="in")
issue_key = CharFilter(field_name="issue_key", lookup_expr="exact")
issue_key__in = CharInFilter(field_name="issue_key", lookup_expr="in")
issue_status_category = ChoiceFilter(
choices=JiraIssue.StatusCategoryChoices.choices
)
issue_status_category__in = ChoiceInFilter(
choices=JiraIssue.StatusCategoryChoices.choices,
field_name="issue_status_category",
lookup_expr="in",
)
class Meta(BaseProviderFilter.Meta):
model = JiraIssue
fields = {
"inserted_at": ["date", "gte", "lte"],
"updated_at": ["date", "gte", "lte"],
"project_key": ["exact", "in"],
}
@@ -0,0 +1,161 @@
import uuid
import api.rls
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0097_attack_paths_scan_db_defaults"),
]
operations = [
migrations.CreateModel(
name="JiraIssue",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("finding_uid", models.CharField(max_length=300)),
("finding_id", models.UUIDField()),
(
"issue_id",
models.CharField(blank=True, max_length=64, null=True),
),
(
"issue_key",
models.CharField(blank=True, max_length=64, null=True),
),
(
"issue_url",
models.URLField(blank=True, max_length=2048, null=True),
),
(
"project_key",
models.CharField(blank=True, max_length=64, null=True),
),
(
"issue_status",
models.CharField(blank=True, max_length=64, null=True),
),
(
"issue_status_category",
models.CharField(
blank=True,
choices=[
("new", "New"),
("indeterminate", "In progress"),
("done", "Done"),
],
max_length=16,
null=True,
),
),
("status_synced_at", models.DateTimeField(blank=True, null=True)),
(
"delivery_attempt_token",
models.UUIDField(blank=True, null=True),
),
(
"delivery_started_at",
models.DateTimeField(blank=True, null=True),
),
(
"integration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="jira_issues",
to="api.integration",
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="jira_issues",
to="api.provider",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "jira_issues",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="jiraissue",
constraint=models.UniqueConstraint(
fields=("tenant_id", "integration_id", "provider_id", "finding_uid"),
name="unique_jira_issue_per_finding",
),
),
migrations.AddConstraint(
model_name="jiraissue",
constraint=models.UniqueConstraint(
condition=models.Q(("delivery_attempt_token__isnull", False)),
fields=("delivery_attempt_token",),
name="unique_jira_delivery_attempt",
),
),
migrations.AddConstraint(
model_name="jiraissue",
constraint=models.CheckConstraint(
condition=models.Q(
("delivery_started_at__isnull", True),
("delivery_attempt_token__isnull", False),
_connector="OR",
),
name="jira_delivery_started_requires_token",
),
),
migrations.AddConstraint(
model_name="jiraissue",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(
("issue_id__isnull", True),
("issue_key__isnull", True),
("issue_url__isnull", True),
("project_key__isnull", True),
),
models.Q(
models.Q(
("issue_id__isnull", False),
("issue_key__isnull", False),
("issue_url__isnull", False),
("project_key__isnull", False),
),
models.Q(("issue_id", ""), _negated=True),
models.Q(("issue_key", ""), _negated=True),
models.Q(("issue_url", ""), _negated=True),
models.Q(("project_key", ""), _negated=True),
),
_connector="OR",
),
name="jira_issue_link_all_or_none",
),
),
migrations.AddConstraint(
model_name="jiraissue",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_jiraissue",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0098_jira_issues"),
]
operations = [
migrations.AddIndex(
model_name="jiraissue",
index=models.Index(
fields=["tenant_id", "provider_id", "finding_uid", "integration_id"],
name="ji_tenant_prov_uid_int_idx",
),
),
]
+109
View File
@@ -3113,3 +3113,112 @@ class TenantComplianceSummary(RowLevelSecurityProtectedModel):
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JiraIssue(RowLevelSecurityProtectedModel):
"""Current Jira issue linked to one finding and Jira integration.
One row per (integration, provider, finding uid). Keyed on the finding ``uid``
rather than the per-scan finding id so the link survives rescans. The current
link stays populated while a replacement attempt is in progress.
"""
class StatusCategoryChoices(models.TextChoices):
NEW = "new", _("New")
INDETERMINATE = "indeterminate", _("In progress")
DONE = "done", _("Done")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
integration = models.ForeignKey(
Integration, on_delete=models.CASCADE, related_name="jira_issues"
)
provider = models.ForeignKey(
Provider, on_delete=models.CASCADE, related_name="jira_issues"
)
finding_uid = models.CharField(max_length=300)
# Findings are partitioned and rotate per scan, so this is not a foreign key.
finding_id = models.UUIDField()
issue_id = models.CharField(max_length=64, null=True, blank=True)
issue_key = models.CharField(max_length=64, null=True, blank=True)
issue_url = models.URLField(max_length=2048, null=True, blank=True)
project_key = models.CharField(max_length=64, null=True, blank=True)
issue_status = models.CharField(max_length=64, null=True, blank=True)
issue_status_category = models.CharField(
max_length=16,
choices=StatusCategoryChoices.choices,
null=True,
blank=True,
)
status_synced_at = models.DateTimeField(null=True, blank=True)
delivery_attempt_token = models.UUIDField(null=True, blank=True)
delivery_started_at = models.DateTimeField(null=True, blank=True)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "jira_issues"
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "integration_id", "provider_id", "finding_uid"),
name="unique_jira_issue_per_finding",
),
models.UniqueConstraint(
fields=("delivery_attempt_token",),
condition=Q(delivery_attempt_token__isnull=False),
name="unique_jira_delivery_attempt",
),
models.CheckConstraint(
condition=(
Q(delivery_started_at__isnull=True)
| Q(delivery_attempt_token__isnull=False)
),
name="jira_delivery_started_requires_token",
),
models.CheckConstraint(
condition=(
Q(
issue_id__isnull=True,
issue_key__isnull=True,
issue_url__isnull=True,
project_key__isnull=True,
)
| (
Q(
issue_id__isnull=False,
issue_key__isnull=False,
issue_url__isnull=False,
project_key__isnull=False,
)
& ~Q(issue_id="")
& ~Q(issue_key="")
& ~Q(issue_url="")
& ~Q(project_key="")
)
),
name="jira_issue_link_all_or_none",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
indexes = [
models.Index(
fields=["tenant_id", "provider_id", "finding_uid", "integration_id"],
name="ji_tenant_prov_uid_int_idx",
),
]
class JSONAPIMeta:
resource_name = "jira-issues"
@property
def is_linked(self) -> bool:
"""Whether the row points at a confirmed Jira issue (not a reservation)."""
return self.issue_id is not None
@property
def is_done(self) -> bool:
return self.issue_status_category == self.StatusCategoryChoices.DONE
+134 -1
View File
@@ -1,9 +1,12 @@
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from allauth.socialaccount.models import SocialApp
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.models import (
JiraIssue,
ProviderComplianceScore,
Resource,
ResourceTag,
@@ -14,7 +17,7 @@ from api.models import (
TenantComplianceSummary,
)
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db import IntegrityError, transaction
@pytest.mark.django_db
@@ -524,3 +527,133 @@ class TestTenantComplianceSummaryModel:
assert summary1.id != summary2.id
assert summary1.requirements_passed != summary2.requirements_passed
@pytest.mark.django_db
class TestJiraIssueModel:
def test_create_jira_issue(
self, jira_integration_fixture, aws_provider, findings_fixture
):
finding = findings_fixture[0]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
issue = JiraIssue.objects.create(
tenant_id=jira_integration_fixture.tenant_id,
integration=jira_integration_fixture,
provider=aws_provider,
finding_uid=finding.uid,
finding_id=finding.id,
issue_id="10001",
issue_key="TEST-1",
issue_url="https://test.atlassian.net/browse/TEST-1",
project_key="TEST",
)
assert issue.is_linked
assert not issue.is_done
assert issue.issue_status_category is None
def test_reservation_is_not_linked(
self, jira_integration_fixture, aws_provider, findings_fixture
):
finding = findings_fixture[0]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
issue = JiraIssue.objects.create(
tenant_id=jira_integration_fixture.tenant_id,
integration=jira_integration_fixture,
provider=aws_provider,
finding_uid=finding.uid,
finding_id=finding.id,
delivery_attempt_token=uuid4(),
)
assert not issue.is_linked
def test_delivery_started_at_requires_attempt_token(
self, jira_integration_fixture, aws_provider, findings_fixture
):
finding = findings_fixture[0]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
with pytest.raises(IntegrityError), transaction.atomic():
JiraIssue.objects.create(
tenant_id=jira_integration_fixture.tenant_id,
integration=jira_integration_fixture,
provider=aws_provider,
finding_uid=finding.uid,
finding_id=finding.id,
delivery_started_at=datetime.now(UTC),
)
def test_unique_per_integration_provider_and_finding_uid(
self, jira_integration_fixture, aws_provider_pair, findings_fixture
):
provider, provider2 = aws_provider_pair
finding = findings_fixture[0]
common = {
"tenant_id": jira_integration_fixture.tenant_id,
"integration": jira_integration_fixture,
"finding_uid": finding.uid,
"finding_id": finding.id,
"project_key": "TEST",
}
with rls_transaction(str(jira_integration_fixture.tenant_id)):
JiraIssue.objects.create(
provider=provider,
issue_id="10001",
issue_key="TEST-1",
issue_url="https://test.atlassian.net/browse/TEST-1",
**common,
)
# Same finding uid on another provider is a different finding
JiraIssue.objects.create(
provider=provider2,
issue_id="10002",
issue_key="TEST-2",
issue_url="https://test.atlassian.net/browse/TEST-2",
**common,
)
with pytest.raises(IntegrityError), transaction.atomic():
JiraIssue.objects.create(
provider=provider,
issue_id="10003",
issue_key="TEST-3",
issue_url="https://test.atlassian.net/browse/TEST-3",
**common,
)
def test_delivery_attempt_token_is_unique_when_present(
self, jira_integration_fixture, aws_provider_pair, findings_fixture
):
provider, provider2 = aws_provider_pair
attempt_token = uuid4()
common = {
"tenant_id": jira_integration_fixture.tenant_id,
"integration": jira_integration_fixture,
"finding_id": findings_fixture[0].id,
"delivery_attempt_token": attempt_token,
}
with rls_transaction(str(jira_integration_fixture.tenant_id)):
JiraIssue.objects.create(
provider=provider,
finding_uid="finding-one",
**common,
)
with pytest.raises(IntegrityError), transaction.atomic():
JiraIssue.objects.create(
provider=provider2,
finding_uid="finding-two",
**common,
)
def test_link_fields_are_all_or_none(
self, jira_integration_fixture, aws_provider, findings_fixture
):
finding = findings_fixture[0]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
with pytest.raises(IntegrityError), transaction.atomic():
JiraIssue.objects.create(
tenant_id=jira_integration_fixture.tenant_id,
integration=jira_integration_fixture,
provider=aws_provider,
finding_uid=finding.uid,
finding_id=finding.id,
issue_id="10001",
)
+46
View File
@@ -1591,6 +1591,52 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_jira_issues_limited_to_visible_providers(
self, authenticated_client_rbac_limited, jira_issues_fixture
):
linked, other_provider_issue, _ = jira_issues_fixture
response = authenticated_client_rbac_limited.get(reverse("jiraissue-list"))
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
response = authenticated_client_rbac_limited.get(
reverse("jiraissue-detail", kwargs={"pk": other_provider_issue.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_task_result_hides_issue_metadata_for_limited_role(
self,
authenticated_client_rbac_limited,
jira_issues_fixture,
tasks_fixture,
):
_, hidden_issue, _ = jira_issues_fixture
task, *_ = tasks_fixture
task.task_runner_task.task_name = "integration-jira"
task.task_runner_task.result = json.dumps(
{
"skipped_count": 1,
"skipped": [
{
"finding_id": str(hidden_issue.finding_id),
"issue_key": hidden_issue.issue_key,
"issue_url": hidden_issue.issue_url,
"issue_status": hidden_issue.issue_status,
}
],
}
)
task.task_runner_task.save(update_fields=["task_name", "result"])
response = authenticated_client_rbac_limited.get(
reverse("task-detail", kwargs={"pk": task.id})
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["result"]["skipped"] == [
{"finding_id": str(hidden_issue.finding_id)}
]
def test_jira_issue_types_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration_fixture
):
+261
View File
@@ -34,6 +34,7 @@ from api.models import (
Integration,
Invitation,
InvitationRoleRelationship,
JiraIssue,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
@@ -5128,6 +5129,40 @@ class TestTaskViewSet:
"label": "True North",
}
def test_tasks_retrieve_sanitizes_jira_result(
self, authenticated_client, tasks_fixture
):
task, *_ = tasks_fixture
task.task_runner_task.task_name = "integration-jira"
task.task_runner_task.result = json.dumps(
{
"created_count": 0,
"failed_count": 0,
"skipped_count": 1,
"skipped": [
{
"finding_id": "00000000-0000-0000-0000-000000000001",
"issue_key": "PRIVATE-1",
"issue_url": "https://private.example/browse/PRIVATE-1",
"issue_status": "In Progress",
}
],
}
)
task.task_runner_task.save(update_fields=["task_name", "result"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["result"] == {
"created_count": 0,
"failed_count": 0,
"skipped_count": 1,
"skipped": [{"finding_id": "00000000-0000-0000-0000-000000000001"}],
}
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
self, authenticated_client, tasks_fixture
):
@@ -13557,8 +13592,234 @@ class TestScheduleViewSet:
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
class TestJiraIssueViewSet:
def test_list_hides_reservations(self, authenticated_client, jira_issues_fixture):
linked, other_provider_issue, reservation = jira_issues_fixture
response = authenticated_client.get(reverse("jiraissue-list"))
assert response.status_code == status.HTTP_200_OK
ids = {item["id"] for item in response.json()["data"]}
assert ids == {str(linked.id), str(other_provider_issue.id)}
assert str(reservation.id) not in ids
def test_retrieve(self, authenticated_client, jira_issues_fixture):
linked, *_ = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-detail", kwargs={"pk": linked.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["type"] == "jira-issues"
attributes = data["attributes"]
assert attributes["finding_uid"] == linked.finding_uid
assert attributes["finding_id"] == str(linked.finding_id)
assert attributes["issue_key"] == "TEST-1"
assert attributes["issue_url"] == "https://test.atlassian.net/browse/TEST-1"
assert attributes["project_key"] == "TEST"
assert attributes["issue_status"] == "To Do"
assert attributes["issue_status_category"] == "new"
assert attributes["status_synced_at"] is not None
assert "delivery_attempt_token" not in attributes
assert "delivery_started_at" not in attributes
relationships = data["relationships"]
assert relationships["provider"]["data"]["id"] == str(linked.provider_id)
assert relationships["integration"]["data"]["id"] == str(linked.integration_id)
def test_retrieve_reservation_returns_404(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-detail", kwargs={"pk": reservation.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_other_tenant_issue_is_hidden(
self, authenticated_client, jira_issues_fixture, tenants_fixture
):
linked, *_ = jira_issues_fixture
other_tenant = tenants_fixture[2]
with rls_transaction(str(other_tenant.id)):
other_provider = Provider.objects.create(
tenant_id=other_tenant.id,
provider=Provider.ProviderChoices.AWS,
uid="999999999999",
alias="other-tenant-provider",
)
other_integration = Integration.objects.create(
tenant_id=other_tenant.id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.JIRA,
configuration={"projects": {"OTHER": "Other project"}},
credentials={
"domain": "other-tenant",
"user_mail": "other-tenant@example.com",
"api_token": "fake-token",
},
)
other_issue = JiraIssue.objects.create(
tenant_id=other_tenant.id,
integration=other_integration,
provider=other_provider,
finding_uid="other-tenant-finding",
finding_id=datetime_to_uuid7(datetime.now(UTC)),
issue_id="20001",
issue_key="OTHER-1",
issue_url="https://other-tenant.atlassian.net/browse/OTHER-1",
project_key="OTHER",
)
response = authenticated_client.get(reverse("jiraissue-list"))
assert response.status_code == status.HTTP_200_OK
ids = {item["id"] for item in response.json()["data"]}
assert str(linked.id) in ids
assert str(other_issue.id) not in ids
response = authenticated_client.get(
reverse("jiraissue-detail", kwargs={"pk": other_issue.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"filter_name, filter_value, expected_keys",
[
("finding_uid", "test_finding_uid_1", {"TEST-1"}),
(
"finding_uid__in",
"test_finding_uid_1,test_finding_uid_other_provider",
{"TEST-1", "TEST-2"},
),
("finding_uid__in", "does-not-exist", set()),
("issue_key", "TEST-2", {"TEST-2"}),
("issue_status_category", "done", {"TEST-2"}),
("issue_status_category__in", "new,indeterminate", {"TEST-1"}),
("project_key", "TEST", {"TEST-1", "TEST-2"}),
("search", "TEST-1", {"TEST-1"}),
],
)
def test_filters(
self,
authenticated_client,
jira_issues_fixture,
filter_name,
filter_value,
expected_keys,
):
response = authenticated_client.get(
reverse("jiraissue-list"), {f"filter[{filter_name}]": filter_value}
)
assert response.status_code == status.HTTP_200_OK
keys = {item["attributes"]["issue_key"] for item in response.json()["data"]}
assert keys == expected_keys
def test_filter_by_provider(self, authenticated_client, jira_issues_fixture):
linked, other_provider_issue, _ = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-list"),
{"filter[provider_id]": str(other_provider_issue.provider_id)},
)
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [
str(other_provider_issue.id)
]
def test_filter_by_integration_and_finding_id(
self, authenticated_client, jira_issues_fixture
):
linked, *_ = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-list"),
{
"filter[integration]": str(linked.integration_id),
"filter[finding_id]": str(linked.finding_id),
},
)
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
def test_invalid_filter(self, authenticated_client, jira_issues_fixture):
response = authenticated_client.get(
reverse("jiraissue-list"), {"filter[invalid]": "x"}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_include_provider(self, authenticated_client, jira_issues_fixture):
response = authenticated_client.get(
reverse("jiraissue-list"), {"include": "provider"}
)
assert response.status_code == status.HTTP_200_OK
included_types = {item["type"] for item in response.json()["included"]}
assert included_types == {"providers"}
def test_read_only(self, authenticated_client, jira_issues_fixture):
linked, *_ = jira_issues_fixture
response = authenticated_client.post(
reverse("jiraissue-list"),
data=json.dumps({"data": {"type": "jira-issues", "attributes": {}}}),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
response = authenticated_client.delete(
reverse("jiraissue-detail", kwargs={"pk": linked.id})
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@pytest.mark.django_db
class TestIntegrationViewSet:
@pytest.mark.parametrize(
("force_retry_attribute", "expected_force_retry"),
(({}, False), ({"force_retry": True}, True)),
)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.jira_integration_task.delay")
def test_jira_dispatch_passes_force_retry_to_task(
self,
mock_jira_task,
mock_task_get,
force_retry_attribute,
expected_force_retry,
authenticated_client,
jira_integration_fixture,
findings_fixture,
tasks_fixture,
):
finding, _ = findings_fixture
prowler_task = tasks_fixture[0]
mock_jira_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
data = {
"data": {
"type": "integrations-jira-dispatches",
"attributes": {
"project_key": "TEST",
"issue_type": "Task",
**force_retry_attribute,
},
}
}
response = authenticated_client.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": jira_integration_fixture.id},
)
+ f"?filter[finding_id]={finding.id}",
data=json.dumps(data),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_jira_task.assert_called_once_with(
tenant_id=str(jira_integration_fixture.tenant_id),
integration_id=str(jira_integration_fixture.id),
project_key="TEST",
issue_type="Task",
finding_ids=[str(finding.id)],
force_retry=expected_force_retry,
)
def test_integrations_list(self, authenticated_client, integrations_fixture):
response = authenticated_client.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
@@ -1,5 +1,6 @@
import os
import re
from typing import Any
from api.models import Integration, IntegrationProviderRelationship, Provider
from api.v1.serializer_utils.base import BaseValidateSerializer
@@ -12,6 +13,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile(
)
def sanitize_integration_task_result(task_name: str | None, result: Any) -> Any:
"""Remove private integration metadata from task results."""
if task_name != "integration-jira" or not isinstance(result, dict):
return result
skipped = result.get("skipped")
if not isinstance(skipped, list):
return result
sanitized_result = result.copy()
sanitized_result["skipped"] = [
{"finding_id": entry["finding_id"]}
for entry in skipped
if isinstance(entry, dict) and "finding_id" in entry
]
return sanitized_result
def replace_integration_providers(
integration: Integration, providers: list[Provider], tenant_id: str
) -> None:
+45 -1
View File
@@ -14,6 +14,7 @@ from api.models import (
IntegrationProviderRelationship,
Invitation,
InvitationRoleRelationship,
JiraIssue,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
@@ -50,6 +51,7 @@ from api.v1.serializer_utils.integrations import (
S3ConfigSerializer,
SecurityHubConfigSerializer,
replace_integration_providers,
sanitize_integration_task_result,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
@@ -637,7 +639,9 @@ class TaskSerializer(RLSSerializer, TaskBase):
@extend_schema_field(serializers.JSONField())
def get_result(self, obj):
return self.get_json_field(obj, "result")
result = self.get_json_field(obj, "result")
task_name = obj.task_runner_task.task_name if obj.task_runner_task else None
return sanitize_integration_task_result(task_name, result)
@extend_schema_field(serializers.JSONField())
def get_task_args(self, obj):
@@ -3217,6 +3221,11 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1):
project_key = serializers.CharField(required=True)
issue_type = serializers.CharField(required=True)
force_retry = serializers.BooleanField(
required=False,
default=False,
help_text="Retry a stale unresolved delivery after Jira returns zero marker matches. This can create a duplicate issue.",
)
class JSONAPIMeta:
resource_name = "integrations-jira-dispatches"
@@ -4149,6 +4158,41 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
# Mute Rules
class JiraIssueSerializer(RLSSerializer):
"""
Read-only view of a Jira issue linked to a finding by a Jira integration.
Rows are keyed on the finding ``uid`` so the same finding maps to the same
issue across scans. ``issue_status`` is the last status Prowler observed in
Jira (refreshed whenever a dispatch touches the finding), not a live value.
"""
class Meta:
model = JiraIssue
fields = [
"id",
"inserted_at",
"updated_at",
"finding_uid",
"finding_id",
"issue_key",
"issue_id",
"issue_url",
"project_key",
"issue_status",
"issue_status_category",
"status_synced_at",
"integration",
"provider",
"url",
]
read_only_fields = fields
included_serializers = {
"provider": "api.v1.serializers.ProviderIncludeSerializer",
}
class MuteRuleSerializer(RLSSerializer):
"""
Serializer for reading MuteRule instances.
+2
View File
@@ -14,6 +14,7 @@ from api.v1.views import (
IntegrationViewSet,
InvitationAcceptViewSet,
InvitationViewSet,
JiraIssueViewSet,
LighthouseConfigViewSet,
LighthouseProviderConfigViewSet,
LighthouseProviderModelsViewSet,
@@ -107,6 +108,7 @@ router.register(
basename="lighthouse-models",
)
router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule")
router.register(r"jira-issues", JiraIssueViewSet, basename="jiraissue")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+55
View File
@@ -54,6 +54,7 @@ from api.filters import (
IntegrationFilter,
IntegrationJiraFindingsFilter,
InvitationFilter,
JiraIssueFilter,
LatestFindingFilter,
LatestFindingGroupFilter,
LatestFindingGroupSummaryFilter,
@@ -89,6 +90,7 @@ from api.models import (
Integration,
Invitation,
InvitationRoleRelationship,
JiraIssue,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
@@ -179,6 +181,7 @@ from api.v1.serializers import (
InvitationCreateSerializer,
InvitationSerializer,
InvitationUpdateSerializer,
JiraIssueSerializer,
LighthouseConfigCreateSerializer,
LighthouseConfigSerializer,
LighthouseConfigUpdateSerializer,
@@ -7001,6 +7004,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
project_key = serializer.validated_data["project_key"]
issue_type = serializer.validated_data["issue_type"]
force_retry = serializer.validated_data["force_retry"]
with transaction.atomic():
task = jira_integration_task.delay(
@@ -7009,6 +7013,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
project_key=project_key,
issue_type=issue_type,
finding_ids=finding_ids,
force_retry=force_retry,
)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
@@ -7486,6 +7491,56 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
return Response(data=serializer.data, status=status.HTTP_200_OK)
# Jira issues
@extend_schema_view(
list=extend_schema(
tags=["Integration"],
summary="List Jira issues linked to findings",
description=(
"Retrieve the Jira issues created from findings through Jira integrations. "
"Each entry links a finding UID to the latest Jira issue created for it, "
"with the last status observed in Jira. Use `filter[finding_uid__in]` "
"and `filter[provider_id]` to check whether specific findings already "
"have a ticket."
),
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve a Jira issue link",
description="Fetch the Jira issue linked to a finding by the link ID.",
),
)
class JiraIssueViewSet(BaseRLSViewSet):
queryset = JiraIssue.objects.all()
serializer_class = JiraIssueSerializer
filterset_class = JiraIssueFilter
http_method_names = ["get"]
search_fields = ["finding_uid", "issue_key"]
ordering = ["-inserted_at"]
ordering_fields = [
"inserted_at",
"updated_at",
"issue_key",
"project_key",
"issue_status",
"status_synced_at",
]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enables unlimited
# visibility or check visibility via provider group, like findings)
required_permissions = []
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return JiraIssue.objects.none()
# Pending reservations have no confirmed Jira issue and are private.
queryset = JiraIssue.objects.filter(
tenant_id=self.request.tenant_id, issue_id__isnull=False
)
if not self.user_role.unlimited_visibility:
queryset = queryset.filter(provider__in=get_providers(self.user_role))
return queryset.select_related("provider", "integration")
# MuteRules
@extend_schema_view(
list=extend_schema(
+47
View File
@@ -20,6 +20,7 @@ from api.models import (
Integration,
IntegrationProviderRelationship,
Invitation,
JiraIssue,
LighthouseConfiguration,
Membership,
MuteRule,
@@ -1470,6 +1471,52 @@ def jira_integration_fixture(tenants_fixture):
)
@pytest.fixture
def jira_issues_fixture(jira_integration_fixture, aws_provider_pair, findings_fixture):
"""Two linked issues (one per provider) and one in-flight reservation."""
provider, provider2 = aws_provider_pair
finding1, finding2 = findings_fixture
tenant_id = jira_integration_fixture.tenant_id
with rls_transaction(str(tenant_id)):
linked = JiraIssue.objects.create(
tenant_id=tenant_id,
integration=jira_integration_fixture,
provider=provider,
finding_uid=finding1.uid,
finding_id=finding1.id,
issue_key="TEST-1",
issue_id="10001",
issue_url="https://test.atlassian.net/browse/TEST-1",
project_key="TEST",
issue_status="To Do",
issue_status_category=JiraIssue.StatusCategoryChoices.NEW,
status_synced_at=datetime.now(UTC),
)
hidden_provider_issue = JiraIssue.objects.create(
tenant_id=tenant_id,
integration=jira_integration_fixture,
provider=provider2,
finding_uid="test_finding_uid_other_provider",
finding_id=finding2.id,
issue_key="TEST-2",
issue_id="10002",
issue_url="https://test.atlassian.net/browse/TEST-2",
project_key="TEST",
issue_status="Done",
issue_status_category=JiraIssue.StatusCategoryChoices.DONE,
status_synced_at=datetime.now(UTC),
)
reservation = JiraIssue.objects.create(
tenant_id=tenant_id,
integration=jira_integration_fixture,
provider=provider,
finding_uid=finding2.uid,
finding_id=finding2.id,
delivery_attempt_token=uuid4(),
)
return linked, hidden_provider_issue, reservation
@pytest.fixture
def backfill_scan_metadata_fixture(scans_fixture, findings_fixture):
for scan_instance in scans_fixture:
+2
View File
@@ -5,6 +5,7 @@ from api.db_utils import batch_delete, rls_transaction
from api.models import (
AttackPathsScan,
Finding,
JiraIssue,
Provider,
ProviderComplianceScore,
Resource,
@@ -86,6 +87,7 @@ def delete_provider(tenant_id: str, pk: str):
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
("Jira Issues", JiraIssue.objects.filter(provider=instance)),
("Findings", Finding.all_objects.filter(scan__provider=instance)),
("Resources", Resource.all_objects.filter(provider=instance)),
("Scans", Scan.all_objects.filter(provider=instance)),
+721 -89
View File
@@ -1,25 +1,37 @@
import os
import time
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from enum import Enum
from glob import glob
from urllib.parse import quote
from uuid import uuid4
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, JiraIssue, Provider
from api.rls import Tenant
from api.utils import initialize_prowler_integration, initialize_prowler_provider
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from django.conf import settings
from django.db import OperationalError
from django.db import IntegrityError, OperationalError
from django.utils import timezone
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding as FindingOutput
from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
from prowler.lib.outputs.jira.jira import Jira
from prowler.lib.outputs.jira.models import (
JiraCreationOutcome,
JiraCreationResult,
JiraIssueLookupOutcome,
JiraIssueReference,
JiraIssueSearchMatch,
JiraIssueSearchOutcome,
JiraIssueSearchResult,
JiraIssueStatusResult,
)
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
@@ -530,114 +542,734 @@ def get_tenant_name(tenant_id: str) -> str:
return ""
# Findings are pre-checked in bounded index lookups however many a dispatch carries.
JIRA_DEDUP_CHUNK_SIZE = 500
JIRA_SKIPPED_REPORT_LIMIT = 100
JIRA_ERROR_REPORT_MAX_LENGTH = 8192
JIRA_FORCE_RETRY_MIN_AGE = timedelta(minutes=15)
class _JiraPendingRecoveryOutcome(Enum):
LINKED = "linked"
NO_MATCH = "no_match"
UNRESOLVED = "unresolved"
def _load_finding_refs(finding_ids: list[str]) -> dict[str, tuple[str, str]]:
"""Map finding id -> (provider id, finding uid) for the batch, in one query."""
refs = {}
for finding_id, provider_id, uid in Finding.all_objects.filter(
id__in=finding_ids
).values_list("id", "scan__provider_id", "uid"):
refs[str(finding_id)] = (str(provider_id), uid)
return refs
def _load_existing_jira_issues(
tenant_id: str, integration_id: str, refs: dict[str, tuple[str, str]]
) -> dict[tuple[str, str], JiraIssue]:
"""Load the Jira issue rows already linked to the batch's findings.
Grouped by provider and chunked so each query is a bounded index lookup on
(tenant, integration, provider, finding_uid).
"""
uids_by_provider: dict[str, list[str]] = {}
for provider_id, uid in refs.values():
uids_by_provider.setdefault(provider_id, []).append(uid)
existing: dict[tuple[str, str], JiraIssue] = {}
for provider_id, uids in uids_by_provider.items():
for start in range(0, len(uids), JIRA_DEDUP_CHUNK_SIZE):
chunk = uids[start : start + JIRA_DEDUP_CHUNK_SIZE]
for row in JiraIssue.objects.filter(
tenant_id=tenant_id,
integration_id=integration_id,
provider_id=provider_id,
finding_uid__in=chunk,
):
existing[(str(row.provider_id), row.finding_uid)] = row
return existing
def _load_jira_issue(
tenant_id: str,
integration_id: str,
provider_id: str,
finding_uid: str,
) -> JiraIssue | None:
"""Reload one ledger identity after a conditional write loses a race."""
with rls_transaction(tenant_id, using=MainRouter.default_db):
return JiraIssue.objects.filter(
tenant_id=tenant_id,
integration_id=integration_id,
provider_id=provider_id,
finding_uid=finding_uid,
).first()
def _refresh_jira_issue_statuses(
jira_integration: Jira, rows: list[JiraIssue]
) -> dict[str, JiraIssueStatusResult]:
"""Fetch linked issue statuses without holding a database transaction."""
if not rows:
return {}
references = [
JiraIssueReference(issue_id=row.issue_id, issue_key=row.issue_key)
for row in rows
]
try:
results = jira_integration.get_issues_status(references)
except Exception:
logger.exception("Could not refresh Jira issue statuses")
results = []
if not isinstance(results, list) or len(results) != len(references):
results = [None] * len(references)
statuses = {}
for row, reference, status_result in zip(rows, references, results):
if (
not isinstance(status_result, JiraIssueStatusResult)
or status_result.reference != reference
):
status_result = JiraIssueStatusResult(
reference=reference,
outcome=JiraIssueLookupOutcome.UNKNOWN,
error_code="malformed_status_result",
error_message="Jira returned an invalid issue status result.",
)
statuses[str(row.id)] = status_result
return statuses
def _apply_jira_issue_status(
tenant_id: str, row: JiraIssue, status_result: JiraIssueStatusResult
) -> bool:
"""Cache a conclusive status if it still describes this linked issue."""
if status_result.outcome not in {
JiraIssueLookupOutcome.OPEN,
JiraIssueLookupOutcome.DONE,
JiraIssueLookupOutcome.MOVED,
}:
return False
current_values = (
status_result.current_issue_id,
status_result.current_issue_key,
status_result.current_issue_url,
status_result.status,
status_result.status_category,
)
if not all(isinstance(value, str) and value.strip() for value in current_values):
return False
if status_result.current_issue_id != row.issue_id:
return False
moved = status_result.outcome == JiraIssueLookupOutcome.MOVED
key_changed = status_result.current_issue_key != row.issue_key
if moved != key_changed:
return False
if status_result.status_category not in {
JiraIssue.StatusCategoryChoices.NEW,
JiraIssue.StatusCategoryChoices.INDETERMINATE,
JiraIssue.StatusCategoryChoices.DONE,
}:
return False
if (
status_result.outcome == JiraIssueLookupOutcome.OPEN
and status_result.status_category == JiraIssue.StatusCategoryChoices.DONE
) or (
status_result.outcome == JiraIssueLookupOutcome.DONE
and status_result.status_category != JiraIssue.StatusCategoryChoices.DONE
):
return False
now = timezone.now()
updates = {
"issue_status": status_result.status[:64],
"issue_status_category": status_result.status_category[:16],
"status_synced_at": now,
"updated_at": now,
}
if moved:
updates.update(
issue_key=status_result.current_issue_key[:64],
issue_url=status_result.current_issue_url[:2048],
)
with rls_transaction(tenant_id, using=MainRouter.default_db):
updated = JiraIssue.objects.filter(
id=row.id,
issue_id=row.issue_id,
issue_key=row.issue_key,
delivery_attempt_token__isnull=True,
).update(**updates)
if not updated:
return False
for field, value in updates.items():
if field != "updated_at":
setattr(row, field, value)
return True
def _update_latest_jira_finding_id(
tenant_id: str, row: JiraIssue, finding_id: str
) -> None:
# Finding IDs are monotonic UUIDv7 values, so ordering reflects scan recency
# and prevents stale dispatches from moving the ledger pointer backward.
with rls_transaction(tenant_id, using=MainRouter.default_db):
JiraIssue.objects.filter(id=row.id, finding_id__lt=finding_id).update(
finding_id=finding_id,
updated_at=timezone.now(),
)
def _reserve_initial_jira_issue(
tenant_id: str,
integration_id: str,
provider_id: str,
finding_uid: str,
finding_id: str,
) -> JiraIssue | None:
"""Reserve a new finding identity; the unique constraint chooses the sender."""
try:
with rls_transaction(tenant_id, using=MainRouter.default_db):
return JiraIssue.objects.create(
tenant_id=tenant_id,
integration_id=integration_id,
provider_id=provider_id,
finding_uid=finding_uid,
finding_id=finding_id,
delivery_attempt_token=uuid4(),
)
except IntegrityError:
return None
def _reserve_jira_issue_replacement(
tenant_id: str, row: JiraIssue, finding_id: str
) -> JiraIssue | None:
"""Reserve replacement of a Done issue while preserving the current link."""
delivery_attempt_token = uuid4()
try:
with rls_transaction(tenant_id, using=MainRouter.default_db):
updated = JiraIssue.objects.filter(
id=row.id,
issue_id=row.issue_id,
issue_key=row.issue_key,
issue_status_category=JiraIssue.StatusCategoryChoices.DONE,
delivery_attempt_token__isnull=True,
).update(
finding_id=finding_id,
delivery_attempt_token=delivery_attempt_token,
updated_at=timezone.now(),
)
except IntegrityError:
return None
if not updated:
return None
row.finding_id = finding_id
row.delivery_attempt_token = delivery_attempt_token
row.delivery_started_at = None
return row
def _start_jira_delivery_attempt(
tenant_id: str, row: JiraIssue, delivery_attempt_token
) -> bool:
"""Mark a reserved delivery as possibly sent; only one worker may do so."""
started_at = timezone.now()
with rls_transaction(tenant_id, using=MainRouter.default_db):
updated = JiraIssue.objects.filter(
id=row.id,
delivery_attempt_token=delivery_attempt_token,
delivery_started_at__isnull=True,
).update(
delivery_started_at=started_at,
updated_at=started_at,
)
if updated:
row.delivery_started_at = started_at
return bool(updated)
def _link_jira_issue(
tenant_id: str,
row: JiraIssue,
delivery_attempt_token,
*,
issue_id: str,
issue_key: str,
issue_url: str,
project_key: str,
finding_id: str,
) -> bool:
"""Link a confirmed issue only if this worker still owns the marker."""
values = (issue_id, issue_key, issue_url, project_key)
if not all(isinstance(value, str) and value.strip() for value in values):
return False
updates = {
"issue_id": issue_id[:64],
"issue_key": issue_key[:64],
"issue_url": issue_url[:2048],
"project_key": project_key[:64],
"finding_id": finding_id,
"issue_status": None,
"issue_status_category": None,
"status_synced_at": None,
"delivery_attempt_token": None,
"delivery_started_at": None,
"updated_at": timezone.now(),
}
filters = {"id": row.id, "delivery_attempt_token": delivery_attempt_token}
if row.issue_id is None:
filters["issue_id__isnull"] = True
else:
filters["issue_id"] = row.issue_id
try:
with rls_transaction(tenant_id, using=MainRouter.default_db):
updated = JiraIssue.objects.filter(**filters).update(**updates)
except IntegrityError:
return False
if not updated:
return False
for field, value in updates.items():
if field != "updated_at":
setattr(row, field, value)
return True
def _release_jira_delivery_attempt(
tenant_id: str, row: JiraIssue, delivery_attempt_token
) -> bool:
"""Release a confirmed failure without removing a previous issue link."""
with rls_transaction(tenant_id, using=MainRouter.default_db):
queryset = JiraIssue.objects.filter(
id=row.id,
delivery_attempt_token=delivery_attempt_token,
)
if row.issue_id is None:
deleted, _ = queryset.filter(issue_id__isnull=True).delete()
released = bool(deleted)
else:
released = bool(
queryset.filter(issue_id=row.issue_id).update(
delivery_attempt_token=None,
delivery_started_at=None,
updated_at=timezone.now(),
)
)
if released:
row.delivery_attempt_token = None
row.delivery_started_at = None
return released
def _skipped_entry(finding_id: str) -> dict:
return {"finding_id": str(finding_id)}
def _recover_pending_jira_issue(
tenant_id: str,
jira_integration: Jira,
row: JiraIssue,
finding_id: str,
) -> _JiraPendingRecoveryOutcome:
"""Link exactly one marker match; every other result remains reserved."""
delivery_attempt_token = row.delivery_attempt_token
if delivery_attempt_token is None or row.delivery_started_at is None:
return _JiraPendingRecoveryOutcome.UNRESOLVED
try:
search_result = jira_integration.search_issues_by_delivery_attempt(
str(delivery_attempt_token)
)
except Exception:
logger.exception("Could not search Jira delivery marker for row %s", row.id)
return _JiraPendingRecoveryOutcome.UNRESOLVED
if not isinstance(search_result, JiraIssueSearchResult):
return _JiraPendingRecoveryOutcome.UNRESOLVED
if search_result.outcome != JiraIssueSearchOutcome.SUCCESS:
return _JiraPendingRecoveryOutcome.UNRESOLVED
if not search_result.matches:
return _JiraPendingRecoveryOutcome.NO_MATCH
if len(search_result.matches) != 1:
return _JiraPendingRecoveryOutcome.UNRESOLVED
match = search_result.matches[0]
if not isinstance(match, JiraIssueSearchMatch) or not all(
isinstance(value, str) and value.strip()
for value in (match.issue_id, match.issue_key, match.issue_url)
):
return _JiraPendingRecoveryOutcome.UNRESOLVED
project_key, separator, _ = match.issue_key.rpartition("-")
if not separator or not project_key:
return _JiraPendingRecoveryOutcome.UNRESOLVED
linked = _link_jira_issue(
tenant_id,
row,
delivery_attempt_token,
issue_id=match.issue_id,
issue_key=match.issue_key,
issue_url=match.issue_url,
project_key=project_key,
finding_id=finding_id,
)
if linked:
return _JiraPendingRecoveryOutcome.LINKED
return _JiraPendingRecoveryOutcome.UNRESOLVED
def _reset_stale_jira_delivery_attempt(
tenant_id: str,
row: JiraIssue,
delivery_attempt_token,
) -> bool:
"""Allow an explicit retry only while the same stale attempt is still owned."""
delivery_started_at = row.delivery_started_at
if delivery_started_at is None:
return False
retry_cutoff = timezone.now() - JIRA_FORCE_RETRY_MIN_AGE
if delivery_started_at > retry_cutoff:
return False
now = timezone.now()
with rls_transaction(tenant_id, using=MainRouter.default_db):
updated = JiraIssue.objects.filter(
id=row.id,
delivery_attempt_token=delivery_attempt_token,
delivery_started_at=delivery_started_at,
).update(
delivery_started_at=None,
updated_at=now,
)
if updated:
row.delivery_started_at = None
return bool(updated)
def _get_jira_send_payload(
tenant_id: str,
finding_id: str,
project_key: str,
issue_type: str,
tenant_info: str,
) -> dict:
"""Build a finding payload inside RLS, ready for an external Jira call."""
with rls_transaction(tenant_id, using=MainRouter.default_db):
finding = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
)
resource = finding.resources.first() if finding.resources.exists() else None
resource_tags = (
resource.get_tags(tenant_id)
if resource and hasattr(resource, "tags")
else {}
)
check_metadata = finding.check_metadata or {}
remediation = check_metadata.get("remediation", {}) or {}
recommendation = remediation.get("recommendation", {}) or {}
remediation_code = remediation.get("code", {}) or {}
provider_type = finding.scan.provider.provider
return {
"check_id": finding.check_id,
"check_title": check_metadata.get("checktitle", ""),
"severity": finding.severity,
"status": finding.status,
"status_extended": finding.status_extended or "",
"provider": provider_type,
"region": resource.region if resource and resource.region else "",
"resource_uid": resource.uid if resource else "",
"resource_name": resource.name if resource else "",
"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.compliance or {},
"project_key": project_key,
"issue_type": issue_type,
"issue_labels": build_jira_issue_labels(
finding_uid=finding.uid,
provider=provider_type,
severity=finding.severity,
check_id=finding.check_id,
),
"finding_url": build_jira_finding_url(finding.uid),
"tenant_info": tenant_info,
}
def _send_reserved_jira_finding(
jira_integration: Jira, payload: dict, delivery_attempt_token
) -> JiraCreationResult:
try:
creation_result = jira_integration.send_finding(
**payload,
delivery_attempt_marker=str(delivery_attempt_token),
)
except Exception:
logger.exception("Jira raised while sending a reserved finding")
return JiraCreationResult(
outcome=JiraCreationOutcome.UNCERTAIN,
delivery_marker=str(delivery_attempt_token),
error_code="unexpected_send_exception",
error_message=JIRA_GENERIC_SEND_ERROR,
)
if not isinstance(creation_result, JiraCreationResult):
return JiraCreationResult(
outcome=JiraCreationOutcome.UNCERTAIN,
delivery_marker=str(delivery_attempt_token),
error_code="invalid_creation_result",
error_message=JIRA_GENERIC_SEND_ERROR,
)
return creation_result
def _jira_creation_error(creation_result: JiraCreationResult) -> str:
message = str(creation_result.error_message or JIRA_GENERIC_SEND_ERROR).strip()
return message[:2048] or JIRA_GENERIC_SEND_ERROR
def send_findings_to_jira(
tenant_id: str,
integration_id: str,
project_key: str,
issue_type: str,
finding_ids: list[str],
force_retry: bool = False,
):
with rls_transaction(tenant_id):
"""Deliver findings through the finding-to-Jira ledger."""
with rls_transaction(tenant_id, using=MainRouter.default_db):
integration = Integration.objects.get(id=integration_id)
jira_integration = initialize_prowler_integration(integration)
tenant_info = get_tenant_name(tenant_id)
finding_refs = _load_finding_refs(finding_ids)
existing = _load_existing_jira_issues(tenant_id, integration_id, finding_refs)
jira_integration = initialize_prowler_integration(integration)
num_tickets_created = 0
status_rows = [
row
for row in existing.values()
if row.is_linked and row.delivery_attempt_token is None
]
statuses = _refresh_jira_issue_statuses(jira_integration, status_rows)
created_count = 0
deferred_count = 0
failed_count = 0
skipped_count = 0
skipped = []
error_messages = []
processed_identities = set()
def record_skip(finding_id: str) -> None:
nonlocal skipped_count
skipped_count += 1
if len(skipped) < JIRA_SKIPPED_REPORT_LIMIT:
skipped.append(_skipped_entry(finding_id))
def record_lost_attempt(
finding_id: str,
identity: tuple[str, str],
previous_issue_id: str | None,
) -> None:
nonlocal deferred_count, failed_count
current = _load_jira_issue(
tenant_id,
integration_id,
identity[0],
identity[1],
)
if current is None:
existing.pop(identity, None)
else:
existing[identity] = current
delivery_is_still_owned = (
current is not None and current.delivery_attempt_token is not None
)
another_issue_was_linked = (
current is not None
and current.issue_id is not None
and (previous_issue_id is None or current.issue_id != previous_issue_id)
)
if another_issue_was_linked:
record_skip(finding_id)
return
if delivery_is_still_owned:
deferred_count += 1
return
failed_count += 1
error_messages.append(JIRA_GENERIC_SEND_ERROR)
for finding_id in finding_ids:
with rls_transaction(tenant_id):
finding_instance = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
finding_id = str(finding_id)
finding_ref = finding_refs.get(finding_id)
if finding_ref is None:
logger.warning("Finding %s could not be loaded for Jira", finding_id)
failed_count += 1
error_messages.append(JIRA_GENERIC_SEND_ERROR)
continue
provider_id, finding_uid = finding_ref
identity = (provider_id, finding_uid)
row = existing.get(identity)
if row is not None:
_update_latest_jira_finding_id(tenant_id, row, finding_id)
if identity in processed_identities:
record_skip(finding_id)
continue
processed_identities.add(identity)
resume_reserved_attempt = (
row is not None
and row.delivery_attempt_token is not None
and row.delivery_started_at is None
)
if (
row is not None
and row.delivery_attempt_token is not None
and not resume_reserved_attempt
):
recovery_outcome = _recover_pending_jira_issue(
tenant_id, jira_integration, row, finding_id
)
# 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)
# Get region
region = resource.region if resource and resource.region else ""
# 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", {})
provider_type = finding_instance.scan.provider.provider
issue_labels = build_jira_issue_labels(
finding_uid=finding_instance.uid,
provider=provider_type,
severity=finding_instance.severity,
check_id=finding_instance.check_id,
)
finding_url = build_jira_finding_url(finding_instance.uid)
try:
# 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=provider_type,
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,
issue_labels=issue_labels,
finding_url=finding_url,
tenant_info=tenant_info,
)
except JiraBaseException as error:
error_message = error.message or JIRA_GENERIC_SEND_ERROR
logger.exception(
"Failed to send finding %s to Jira: %s", finding_id, error_message
)
error_messages.append(error_message)
if recovery_outcome == _JiraPendingRecoveryOutcome.LINKED:
created_count += 1
continue
except Exception:
logger.exception("Failed to send finding %s to Jira", finding_id)
error_messages.append(JIRA_GENERIC_SEND_ERROR)
if (
force_retry
and recovery_outcome == _JiraPendingRecoveryOutcome.NO_MATCH
and _reset_stale_jira_delivery_attempt(
tenant_id, row, row.delivery_attempt_token
)
):
logger.warning(
"Force retrying stale Jira delivery for tenant %s, integration %s, provider %s, finding %s",
tenant_id,
integration_id,
provider_id,
finding_uid,
)
resume_reserved_attempt = True
else:
if (
force_retry
and recovery_outcome == _JiraPendingRecoveryOutcome.NO_MATCH
):
deferred_count += 1
else:
record_skip(finding_id)
continue
if result:
num_tickets_created += 1
needs_replacement = bool(resume_reserved_attempt and row.is_linked)
if not resume_reserved_attempt and row is not None and row.is_linked:
status_result = statuses.get(str(row.id))
if status_result is None or not _apply_jira_issue_status(
tenant_id, row, status_result
):
record_skip(finding_id)
continue
needs_replacement = (
status_result.outcome == JiraIssueLookupOutcome.DONE
or (
status_result.outcome == JiraIssueLookupOutcome.MOVED
and status_result.status_category
== JiraIssue.StatusCategoryChoices.DONE
)
)
if not needs_replacement:
record_skip(finding_id)
continue
elif not resume_reserved_attempt and row is not None:
record_skip(finding_id)
continue
try:
payload = _get_jira_send_payload(
tenant_id,
finding_id,
project_key,
issue_type,
tenant_info,
)
except Exception:
logger.exception("Failed to build finding %s for Jira", finding_id)
failed_count += 1
error_messages.append(JIRA_GENERIC_SEND_ERROR)
continue
previous_issue_id = row.issue_id if needs_replacement else None
if not resume_reserved_attempt:
if needs_replacement:
row = _reserve_jira_issue_replacement(tenant_id, row, finding_id)
else:
row = _reserve_initial_jira_issue(
tenant_id,
integration_id,
provider_id,
finding_uid,
finding_id,
)
if row is None:
record_lost_attempt(finding_id, identity, previous_issue_id)
continue
existing[identity] = row
delivery_attempt_token = row.delivery_attempt_token
if delivery_attempt_token is None or not _start_jira_delivery_attempt(
tenant_id, row, delivery_attempt_token
):
record_lost_attempt(finding_id, identity, previous_issue_id)
continue
creation_result = _send_reserved_jira_finding(
jira_integration, payload, delivery_attempt_token
)
if creation_result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS:
linked = _link_jira_issue(
tenant_id,
row,
delivery_attempt_token,
issue_id=creation_result.issue_id,
issue_key=creation_result.issue_key,
issue_url=creation_result.issue_url,
project_key=project_key,
finding_id=finding_id,
)
if linked:
created_count += 1
logger.info(
"Finding %s sent to Jira as %s",
finding_id,
result.get("key") if isinstance(result, dict) else result,
creation_result.issue_key,
)
else:
error_message = JIRA_GENERIC_SEND_ERROR
logger.error(error_message)
error_messages.append(error_message)
failed_count += 1
error_messages.append(JIRA_GENERIC_SEND_ERROR)
continue
failed_count += 1
error_messages.append(_jira_creation_error(creation_result))
if creation_result.outcome in {
JiraCreationOutcome.CONFIRMED_REJECTION,
JiraCreationOutcome.RETRYABLE_FAILURE,
}:
_release_jira_delivery_attempt(tenant_id, row, delivery_attempt_token)
if row.issue_id is None:
existing.pop(identity, None)
result = {
"created_count": num_tickets_created,
"failed_count": len(finding_ids) - num_tickets_created,
"created_count": created_count,
"deferred_count": deferred_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
}
if error_messages:
result["error"] = "; ".join(dict.fromkeys(error_messages))
result["error"] = "; ".join(dict.fromkeys(error_messages))[
:JIRA_ERROR_REPORT_MAX_LENGTH
]
if skipped:
result["skipped"] = skipped
return result
+9 -3
View File
@@ -1378,8 +1378,8 @@ def security_hub_integration_task(
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered,
# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues).
# A Jira POST can remain ambiguous after worker loss, so this manual task stays
# early-acknowledged and relies on a later explicit send for marker recovery.
@shared_task(
base=RLSTask,
name="integration-jira",
@@ -1392,9 +1392,15 @@ def jira_integration_task(
project_key: str,
issue_type: str,
finding_ids: list[str],
force_retry: bool = False,
):
return send_findings_to_jira(
tenant_id, integration_id, project_key, issue_type, finding_ids
tenant_id,
integration_id,
project_key,
issue_type,
finding_ids,
force_retry=force_retry,
)
+17 -1
View File
@@ -2,7 +2,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from api.attack_paths import database as graph_database
from api.models import Provider, Tenant, TenantComplianceSummary
from api.models import JiraIssue, Provider, Tenant, TenantComplianceSummary
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider, delete_tenant
@@ -33,6 +33,22 @@ class TestDeleteProvider:
str(instance.id),
)
def test_delete_provider_removes_jira_issues(self, jira_issues_fixture):
linked, other_provider_issue, reservation = jira_issues_fixture
provider = linked.provider
tenant_id = str(provider.tenant_id)
with (
patch("tasks.jobs.deletion.graph_database.get_database_name"),
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
):
delete_provider(tenant_id, provider.id)
remaining = set(JiraIssue.objects.values_list("id", flat=True))
assert linked.id not in remaining
assert reservation.id not in remaining
# Issues of other providers are untouched
assert other_provider_issue.id in remaining
def test_delete_provider_does_not_exist(self, tenants_fixture):
with (
patch(
File diff suppressed because it is too large Load Diff
+89
View File
@@ -4,6 +4,95 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.41.0" description="September 2, 2026">
### 📥 Scans — Import Findings from the Browser
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Findings produced outside the platform, by the Prowler CLI or a CI pipeline, can now be brought into the app without leaving the browser. The Scans page gains an "Import Findings" dialog that takes a Prowler `.ocsf.json` report by drag-and-drop or file picker, hands it to the ingestion API and tracks the job to completion, reporting how many records were processed and how many were invalid. Files that are not a `.ocsf.json` report, or are empty, are refused before any upload starts, and a rejected upload or a failed status poll can be retried in place. The dialog is available to roles holding the Manage Ingestions permission.
![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png)
![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png)
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-ui).
### 🎫 Jira Integration — Finding Reference in Every Issue
Every Jira issue created from a finding now carries a stable reference back to it. Issues are labeled `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`, so they can be filtered, searched with JQL or matched by automation; labels are sanitized to Jira's limits so a long or unusual value never blocks issue creation. The issue also links back to the finding in Prowler, filtered by its UID so the link keeps working after later scans, and names the Prowler organization that sent it. Prowler Cloud always includes the link; Prowler Local Server enables it by setting `DJANGO_UI_BASE_URL` in the API environment.
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
### 📚 Compliance — CIS Google Workspace Foundations Benchmark v1.4.0
Prowler now ships the CIS Google Workspace Foundations Benchmark v1.4.0. Alongside the new framework, the Google Workspace checks mapped to CIS were reworked to evaluate the benchmark's full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass. Expect new `FAIL` findings on domains that rely on those defaults. Three accuracy fixes also land:
- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report `MANUAL` instead of judging domain-wide values that a group or a sub-organizational unit overrides; a domain-wide failure is still reported as such, with the override noted.
- `rules_*_alert_configured` no longer passes a rule whose delivery to the alert center is disabled.
- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting, since Google enforces strong passwords by default.
`security_login_challenges_configured` was unmapped from CIS Google Workspace requirement 4.1.4.1 (Post-SSO verification) and `security_2sv_enforced` from CISA SCuBA `GWS.COMMONCONTROLS.1.1` (phishing-resistant MFA), because neither check can prove what those requirements ask for.
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
### 🔍 Checks
Ten new AWS checks land in this release, eight of them contributed by @tamg-aws. Thank you!
#### Amazon Bedrock AgentCore
- `iam_policy_passrole_to_bedrock_agentcore_restricted` flags customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy could run agent code under any role in the account.
- `iam_policy_no_agentcore_workload_access_token_wildcard` flags customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own.
- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` verifies that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy. The log group prefixes are configurable through `agentcore_log_group_name_prefixes` in `config.yaml`.
#### Amazon GuardDuty
- `guardduty_runtime_monitoring_enabled` flags detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS.
- `guardduty_ai_protection_enabled` flags detectors without AI Protection, which analyzes CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI. A detector that does not report the feature is `MANUAL` rather than `FAIL`.
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS.
#### Amazon ECR and EKS
- `ecr_registry_enhanced_scanning_enabled` verifies that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, reporting `MANUAL` when the registry scanning configuration cannot be read.
- `eks_cluster_vpc_cni_network_policy_enforced` flags EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, reporting `MANUAL` where the EKS API cannot show the setting.
#### AWS IAM, Elastic Beanstalk and MemoryDB
- `iam_role_service_trust_restricts_source_to_account` flags IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate.
- `elasticbeanstalk_environment_no_secrets_in_configuration` scans the option settings of every Elastic Beanstalk environment for hardcoded secrets. Thanks to @haneul-24!
- `memorydb_cluster_in_transit_encryption_enabled` verifies that MemoryDB clusters have in-transit encryption (TLS) enabled. Thanks to @UTKARSH698!
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 🐳 Image Provider — On-Premises Registries
Scanning registries that live on private networks is now supported end to end. `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` takes a comma-separated list of IPs and CIDRs the provider may reach, while every other non-public address, including link-local and loopback, stays blocked by the SSRF guard. Authentication negotiation is also more resilient: the provider falls back to Basic when a registry such as Harbor rejects the negotiated bearer token, and switches to a bearer token when the server answers a Basic or anonymous request with a Bearer challenge. `--registry-insecure` now propagates to Trivy through `TRIVY_INSECURE`, so images behind self-signed certificates can be pulled and scanned, not just enumerated. The flag now disables certificate validation for the image pull too, so keep it for trusted internal registries only.
Registry scans also skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations), no longer abort the whole scan when Trivy fails on a single image, and enumerate repositories in parallel instead of one request at a time.
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#on-premises-registries-and-private-networks).
### 🛠️ Prowler MCP Server — Tool Failures Reported as Errors
Prowler Local Server tools now report a failure as an MCP tool execution error (`isError: true`, with the explanation in `content`) instead of a successful result carrying an `{"error": ...}` object, which clients and models read as a success. The Prowler Documentation and Prowler Hub tools follow the same rule: `prowler_docs_search` no longer reports a failed search as zero matches, `prowler_docs_get_document` no longer reports a failed fetch as a missing page, and `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now name the provider a check ID actually belongs to instead of reporting it as nonexistent. `prowler_get_compliance_framework_state_details` also rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider.
Read more in the [Prowler MCP documentation](https://docs.prowler.com/getting-started/products/prowler-mcp).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @tamg-aws: GuardDuty unified Runtime Monitoring and AI Protection checks ([#12564](https://github.com/prowler-cloud/prowler/pull/12564)), EKS VPC CNI network policy check ([#12661](https://github.com/prowler-cloud/prowler/pull/12661)), ECR enhanced scanning check ([#12660](https://github.com/prowler-cloud/prowler/pull/12660)), Bedrock AgentCore IAM and service trust checks ([#12664](https://github.com/prowler-cloud/prowler/pull/12664)), AgentCore log group data protection check ([#12662](https://github.com/prowler-cloud/prowler/pull/12662)), and fixes to ECR scan frequency ([#12560](https://github.com/prowler-cloud/prowler/pull/12560)), CloudWatch metric filters ([#12561](https://github.com/prowler-cloud/prowler/pull/12561)) and SageMaker direct internet access ([#12659](https://github.com/prowler-cloud/prowler/pull/12659))
- @haneul-24: AWS `elasticbeanstalk_environment_no_secrets_in_configuration` check ([#12378](https://github.com/prowler-cloud/prowler/pull/12378))
- @UTKARSH698: AWS `memorydb_cluster_in_transit_encryption_enabled` check ([#12246](https://github.com/prowler-cloud/prowler/pull/12246))
- @ye11oc4t: GitHub repository discovery pagination for unscoped scans ([#12460](https://github.com/prowler-cloud/prowler/pull/12460))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.41.0) for the complete list of changes.
</Update>
<Update label="v5.40.0" description="August 28, 2026">
### 💬 Slack Integration — Alert Channel Destinations
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

@@ -311,7 +311,7 @@ Skipping TLS verification disables certificate validation for registry connectio
#### On-Premises Registries and Private Networks
<VersionBadge version="5.42.0" />
<VersionBadge version="5.41.0" />
By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly:
@@ -136,6 +136,24 @@ Every Jira issue created from a single Finding carries a stable reference back t
Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJANGO_UI_BASE_URL` in the API environment (for example, `https://prowler.example.com`) to enable it. When the variable is empty, the issue is created without the link.
### Sending a Finding That Already Has a Jira Issue
<VersionBadge version="5.42.0" />
Prowler remembers each confirmed Jira issue created for a Finding, keyed by the Finding UID, so sending the same Finding again does not create a duplicate issue:
* If the linked issue is still open in Jira, the Finding is skipped. The task result reports `skipped_count` and the skipped Finding IDs; use `GET /api/v1/jira-issues` to read confirmed Jira references.
* If the linked issue moved to a new Jira key, Prowler verifies its issue ID, refreshes the saved key and URL, and skips the Finding while the issue remains open.
* If the linked issue has a Jira status in the **Done** category, Prowler creates a replacement and links the Finding to the new issue after Jira confirms it.
* If Jira reports that the issue is missing or forbidden, or its status cannot be determined safely, Prowler keeps the existing link and skips the Finding.
* If another task already owns the delivery, Prowler reports the Finding as deferred instead of treating it as already ticketed. Run the same send action after the active task finishes.
The link, and the last status observed in Jira, are available through the API at `GET /api/v1/jira-issues` (filter by `finding_uid`, `finding_uid__in`, `provider_id`, `integration`, `issue_key`, `issue_status_category` or `issue_status_category__in`). Each Jira integration keeps its own links, so the same Finding can have one issue per integration.
Prowler uses a private delivery marker to check whether Jira created an issue when a delivery result is uncertain. A reservation that stopped before the delivery attempt began can be resumed safely by a later explicit send. After an attempt starts, Prowler searches Jira by the marker: exactly one matching issue completes the link, while no match, multiple matches, or a lookup error keeps the reservation and prevents an automatic retry.
An authorized API caller can repeat a dispatch with `force_retry: true` when an attempt has been pending for at least 15 minutes. Prowler retries only when Jira returns zero matches for the saved marker, and it reuses that marker. This action accepts a duplicate-issue risk because an empty Jira search cannot prove that Jira never received the original request. Multiple matches and lookup errors remain blocked for manual investigation.
## Integration Status
Monitor and manage your Jira integrations through the management interface:
@@ -237,7 +255,9 @@ If you don't have `jq` installed, run the command without `| jq`.
"state": "completed",
"result": {
"created_count": 0,
"failed_count": 1
"deferred_count": 0,
"failed_count": 1,
"skipped_count": 0
},
"task_args": {
"integration_id": "a476c2c0-0a00-4720-bfb9-286e9eb5c7bd",
@@ -260,7 +280,9 @@ If you don't have `jq` installed, run the command without `| jq`.
"state": "completed",
"result": {
"created_count": 1,
"failed_count": 0
"deferred_count": 0,
"failed_count": 0,
"skipped_count": 0
},
"task_args": {
"integration_id": "a476c2c0-0a00-4720-bfb9-286e9eb5c7bd",
@@ -288,4 +310,6 @@ If you don't have `jq` installed, run the command without `| jq`.
How to read it:
* "created_count": number of Jira issues successfully created.
* "deferred_count": number of Findings owned by another delivery task. Run the same send action after that task finishes.
* "failed_count": number of Jira issues that could not be created. If `failed_count > 0` or the issue does not appear in Jira, please contact us so we can assist while detailed logs are not available through the UI.
* "skipped_count": number of Findings not sent because an existing Jira issue does not need replacement, a pending delivery remains unresolved, or an equivalent Finding was already processed in the request.
@@ -261,7 +261,7 @@ To grant all administrative permissions, select the **Grant all admin permission
The following permissions are available exclusively in **Prowler Cloud**:
**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
**Manage Ingestions:** Submit and manage findings ingestion jobs. Required to upload OCSF scan results from the Scans page, with the `--push-to-cloud` CLI flag or through the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
@@ -1,7 +1,7 @@
---
title: 'Import Findings'
sidebarTitle: 'Import Findings'
description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI'
description: 'Upload OCSF scan results to Prowler Cloud from the UI, the CLI or the API'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.19.0" />
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. Reports can be imported from the Scans page in the Prowler Cloud UI, pushed by the CLI with `--push-to-cloud`, or submitted through the API.
<SubscriptionBanner />
@@ -132,10 +132,32 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF
## Required Permissions
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted from the Scans page, via the API or with `--push-to-cloud`.
For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
## Using the UI
<VersionBadge version="5.41.0" />
The Scans page imports a Prowler OCSF report from the browser, with no CLI or API key involved. The import runs as a regular ingestion job, so the [status values](#ingestion-status-values), the [billing impact](#billing-impact) and the [errors endpoint](#get-ingestion-errors) apply as they do for the CLI and the API.
1. Go to **Scans** and click **Import Findings**. The button is shown only to roles with the **Manage Ingestions** permission.
![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png)
2. Drag a `.ocsf.json` report onto the drop area, or click **Select File** to pick one. The dialog takes one file per import. A file whose name does not end in `.ocsf.json`, or an empty file, is rejected before the upload starts.
![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png)
3. Click **Start import**. The dialog uploads the report, creates the ingestion job and follows its status until the job finishes. On completion it reports the total number of records, how many were processed and how many were invalid.
Closing the dialog while an import is running does not cancel the job. When the job completes in the background, a notification confirms it and the imported findings appear in Scans.
If the upload is rejected or the job fails, the dialog shows the reason and a **Retry import** button that sends the same file again. A failed job also shows the progress it reported before failing. A different file can be selected instead of retrying. If the status check fails after the upload was accepted, **Retry status** resumes tracking the same job without uploading the file again.
Invalid records are counted in the summary but not listed in the dialog. To see why each one was rejected, [list the ingestion jobs](#list-ingestion-jobs) through the API and query the [errors endpoint](#get-ingestion-errors) for that job.
## Using the CLI
The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads.
+17
View File
@@ -4,6 +4,23 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.12.0] (Prowler v5.41.0)
### 🚀 Added
- Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
### 🔄 Changed
- `prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
### 🐞 Fixed
- `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist [(#12533)](https://github.com/prowler-cloud/prowler/pull/12533)
- `prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page [(#12534)](https://github.com/prowler-cloud/prowler/pull/12534)
---
## [0.11.0] (Prowler v5.40.0)
### 🚀 Added
@@ -1 +0,0 @@
Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success
@@ -1 +0,0 @@
`prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about
@@ -1 +0,0 @@
`prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page
@@ -1 +0,0 @@
`prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist
+43
View File
@@ -4,6 +4,49 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.41.0] (Prowler v5.41.0)
### 🚀 Added
- `memorydb_cluster_in_transit_encryption_enabled` check for AWS provider, verifying MemoryDB clusters have in-transit encryption (TLS) enabled [(#12246)](https://github.com/prowler-cloud/prowler/pull/12246)
- `elasticbeanstalk_environment_no_secrets_in_configuration` check for AWS provider, scanning the option settings of every Elastic Beanstalk environment for hardcoded secrets [(#12378)](https://github.com/prowler-cloud/prowler/pull/12378)
- CIS Google Workspace Foundations Benchmark v1.4.0 compliance framework [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
- `Jira.send_finding()` returns typed creation outcomes with the issue key, immutable ID and browse URL, and supports length-safe finding and delivery-attempt labels plus marker lookup for uncertain deliveries [(#12539)](https://github.com/prowler-cloud/prowler/pull/12539)
- `Jira.get_issues_status()` resolves issue references by immutable ID in batches and returns explicit open, done, moved, missing, forbidden or unknown outcomes without treating missing issues as deleted [(#12539)](https://github.com/prowler-cloud/prowler/pull/12539)
- `guardduty_ai_protection_enabled` check for AWS provider, flagging GuardDuty detectors without AI Protection, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI; a detector that does not report the feature is `MANUAL` rather than `FAIL` [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564)
- `guardduty_runtime_monitoring_enabled` check for AWS provider, flagging GuardDuty detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564)
- `ecr_registry_enhanced_scanning_enabled` check for AWS provider, verifying that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, and reporting MANUAL when the registry scanning configuration cannot be read [(#12660)](https://github.com/prowler-cloud/prowler/pull/12660)
- `eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting [(#12661)](https://github.com/prowler-cloud/prowler/pull/12661)
- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` check for AWS provider, verifying that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy [(#12662)](https://github.com/prowler-cloud/prowler/pull/12662)
- `iam_policy_no_agentcore_workload_access_token_wildcard` check for AWS provider, flagging customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own, which AWS documents as the only binding on the unverified user ID the token is issued for [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664)
- `iam_policy_passrole_to_bedrock_agentcore_restricted` check for AWS provider, flagging customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy can run agent code under any role in the account [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664)
- `iam_role_service_trust_restricts_source_to_account` check for AWS provider, flagging IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664)
- `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` environment variable so the image provider can reach container registries on allowlisted private networks, keeping every other non-public address blocked [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678)
- `PROWLER_AWS_PARTITION` environment variable to select the AWS partition used for STS credential validation and scan bootstrap, with a clear error when the account belongs to a different partition [(#12680)](https://github.com/prowler-cloud/prowler/pull/12680)
### 🔄 Changed
- Google Workspace checks mapped to CIS evaluate the full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
- `security_login_challenges_configured` and `security_2sv_enforced` unmapped from CIS Google Workspace 4.1.4.1 and CISA SCuBA 0.6 `GWS.COMMONCONTROLS.1.1`, whose Post-SSO verification and phishing-resistant MFA requirements neither check can prove [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
### 🐞 Fixed
- GitHub repository discovery for unscoped scans now paginates beyond the first 100 accessible repositories instead of silently scanning only the first page [(#12460)](https://github.com/prowler-cloud/prowler/pull/12460)
- `rules_*_alert_configured` checks no longer pass a rule whose delivery to the alert center is disabled, the setting behind the benchmark's "Ensure that Alerts is set to On" [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting: Google enforces strong passwords by default, so an unset value is the secure default and not a missing configuration [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report MANUAL instead of judging domain-wide values that a group or a sub-organizational unit overrides, or that were dropped because the root organizational unit could not be resolved; a domain-wide failure is still reported as such, with the override noted [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513)
- `ecr_registry_scan_images_on_push_enabled` no longer passes a registry whose scanning rules are all `MANUAL`, nor describes a `CONTINUOUS_SCAN` registry as scanning on push; each rule's `scanFrequency` is now read instead of inferred from a rule's presence [(#12560)](https://github.com/prowler-cloud/prowler/pull/12560)
- CloudWatch log metric filter checks no longer crash with `AttributeError` when the account has a metric filter whose log group was not retrieved [(#12561)](https://github.com/prowler-cloud/prowler/pull/12561)
- `guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring; the GuardDuty service now reads the `RUNTIME_MONITORING` feature, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564)
- Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks [(#12645)](https://github.com/prowler-cloud/prowler/pull/12645)
- `sagemaker_notebook_instance_without_direct_internet_access_configured` check logic to read the `DirectInternetAccess` setting instead of `RootAccess`, failing a notebook instance with direct internet access enabled even when root access is disabled [(#12659)](https://github.com/prowler-cloud/prowler/pull/12659)
- Basic authentication fallback in the image provider when a registry rejects the negotiated bearer token, so registries like Harbor that guard catalog listing behind Basic can be enumerated [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678)
- Registry catalog listing when the server answers with a Bearer challenge after negotiating Basic (or anonymous) authentication, switching to a bearer token obtained from the challenge instead of failing [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678)
- `--registry-insecure` now propagates to Trivy via `TRIVY_INSECURE`, so images in registries with self-signed certificates can be pulled and scanned, not just enumerated [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678)
- Registry scans in the Image provider now skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations) and no longer abort the whole scan when Trivy fails on a single discovered image; registry enumeration also runs in parallel instead of one request at a time [(#12695)](https://github.com/prowler-cloud/prowler/pull/12695)
---
## [5.40.0] (Prowler v5.40.0)
### 🚀 Added
@@ -1 +0,0 @@
`PROWLER_AWS_PARTITION` environment variable to select the AWS partition used for STS credential validation and scan bootstrap, with a clear error when the account belongs to a different partition
@@ -1 +0,0 @@
CIS Google Workspace Foundations Benchmark v1.4.0 compliance framework
@@ -1 +0,0 @@
`cloudwatch_log_group_agentcore_data_protection_policy_enabled` check for AWS provider, verifying that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy
@@ -1 +0,0 @@
CloudWatch log metric filter checks no longer crash with `AttributeError` when the account has a metric filter whose log group was not retrieved
@@ -1 +0,0 @@
`ecr_registry_enhanced_scanning_enabled` check for AWS provider, verifying that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, and reporting MANUAL when the registry scanning configuration cannot be read
@@ -1 +0,0 @@
`ecr_registry_scan_images_on_push_enabled` no longer passes a registry whose scanning rules are all `MANUAL`, nor describes a `CONTINUOUS_SCAN` registry as scanning on push; each rule's `scanFrequency` is now read instead of inferred from a rule's presence
@@ -1 +0,0 @@
`eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting
@@ -1 +0,0 @@
`elasticbeanstalk_environment_no_secrets_in_configuration` check for AWS provider, scanning the option settings of every Elastic Beanstalk environment for hardcoded secrets
@@ -1 +0,0 @@
GitHub repository discovery for unscoped scans now paginates beyond the first 100 accessible repositories instead of silently scanning only the first page
@@ -1 +0,0 @@
`security_2sv_enforced` and `security_2sv_hardware_keys_admins` report MANUAL instead of judging domain-wide values that a group or a sub-organizational unit overrides, or that were dropped because the root organizational unit could not be resolved; a domain-wide failure is still reported as such, with the override noted
@@ -1 +0,0 @@
`rules_*_alert_configured` checks no longer pass a rule whose delivery to the alert center is disabled, the setting behind the benchmark's "Ensure that Alerts is set to On"
@@ -1 +0,0 @@
Google Workspace checks mapped to CIS evaluate the full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass
@@ -1 +0,0 @@
`security_login_challenges_configured` and `security_2sv_enforced` unmapped from CIS Google Workspace 4.1.4.1 and CISA SCuBA 0.6 `GWS.COMMONCONTROLS.1.1`, whose Post-SSO verification and phishing-resistant MFA requirements neither check can prove
@@ -1 +0,0 @@
`security_password_policy_strong` no longer fails a domain that never touched the password strength setting: Google enforces strong passwords by default, so an unset value is the secure default and not a missing configuration
@@ -1 +0,0 @@
`guardduty_ai_protection_enabled` check for AWS provider, flagging GuardDuty detectors without AI Protection, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI; a detector that does not report the feature is `MANUAL` rather than `FAIL`
@@ -1 +0,0 @@
`guardduty_runtime_monitoring_enabled` check for AWS provider, flagging GuardDuty detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS
@@ -1 +0,0 @@
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring; the GuardDuty service now reads the `RUNTIME_MONITORING` feature, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS
@@ -1 +0,0 @@
`iam_policy_passrole_to_bedrock_agentcore_restricted` check for AWS provider, flagging customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy can run agent code under any role in the account
@@ -1 +0,0 @@
`iam_policy_no_agentcore_workload_access_token_wildcard` check for AWS provider, flagging customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own, which AWS documents as the only binding on the unverified user ID the token is issued for
@@ -1 +0,0 @@
`iam_role_service_trust_restricts_source_to_account` check for AWS provider, flagging IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate
@@ -1 +0,0 @@
`PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` environment variable so the image provider can reach container registries on allowlisted private networks, keeping every other non-public address blocked
@@ -1 +0,0 @@
Basic authentication fallback in the image provider when a registry rejects the negotiated bearer token, so registries like Harbor that guard catalog listing behind Basic can be enumerated
@@ -1 +0,0 @@
Registry catalog listing when the server answers with a Bearer challenge after negotiating Basic (or anonymous) authentication, switching to a bearer token obtained from the challenge instead of failing
@@ -1 +0,0 @@
`--registry-insecure` now propagates to Trivy via `TRIVY_INSECURE`, so images in registries with self-signed certificates can be pulled and scanned, not just enumerated
@@ -1 +0,0 @@
Registry scans in the Image provider now skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations) and no longer abort the whole scan when Trivy fails on a single discovered image; registry enumeration also runs in parallel instead of one request at a time
@@ -1 +0,0 @@
`Jira.send_finding()` returns typed creation outcomes with the issue key, immutable ID and browse URL, and supports length-safe finding and delivery-attempt labels plus marker lookup for uncertain deliveries
@@ -1 +0,0 @@
`Jira.get_issues_status()` resolves issue references by immutable ID in batches and returns explicit open, done, moved, missing, forbidden or unknown outcomes without treating missing issues as deleted
@@ -1 +0,0 @@
`memorydb_cluster_in_transit_encryption_enabled` check for AWS provider, verifying MemoryDB clusters have in-transit encryption (TLS) enabled
@@ -1 +0,0 @@
Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks
@@ -1 +0,0 @@
`sagemaker_notebook_instance_without_direct_internet_access_configured` check logic to read the `DirectInternetAccess` setting instead of `RootAccess`, failing a notebook instance with direct internet access enabled even when root access is disabled
+18
View File
@@ -4,6 +4,24 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.41.0] (Prowler v5.41.0)
### 🚀 Added
- Finding-report imports from Scans for Cloud and Private Cloud deployments [(#12554)](https://github.com/prowler-cloud/prowler/pull/12554)
- Slack integration: the connection check leaves its result on the card instead of only in a toast, naming the channel Slack refused or the channels it reached (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677)
### 🔄 Changed
- Slack integration: the bot is referred to as `@Prowler Cloud`, identifiers are set as inline code, and the private-channel hint links to its docs (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677)
- Slack integration: a selected private channel carries the same visible `Private` badge as the channel list, replacing the lock icon on the chip (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677)
### 🐞 Fixed
- Cached permissions now refresh from `/users/me?include=roles` after access token rotation [(#12640)](https://github.com/prowler-cloud/prowler/pull/12640)
---
## [1.40.0] (Prowler v5.40.0)
### 🚀 Added
@@ -1 +0,0 @@
Finding-report imports from Scans for Cloud and Private Cloud deployments
@@ -1 +0,0 @@
Cached permissions now refresh from `/users/me?include=roles` after access token rotation
@@ -1 +0,0 @@
Slack integration: the connection check leaves its result on the card instead of only in a toast, naming the channel Slack refused or the channels it reached (Prowler Cloud only)
@@ -1 +0,0 @@
Slack integration: a selected private channel carries the same visible `Private` badge as the channel list, replacing the lock icon on the chip (Prowler Cloud only)
@@ -1 +0,0 @@
Slack integration: the bot is referred to as `@Prowler Cloud`, identifiers are set as inline code, and the private-channel hint links to its docs (Prowler Cloud only)