mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 18:44:24 +00:00
feat(api): add Jira issue recovery API
This commit is contained in:
@@ -1 +1 @@
|
||||
Jira issues created from findings are now tracked per finding UID in the new `jira_issues` table and exposed through `GET /api/v1/jira-issues`; sending a finding that already has an open Jira issue skips it (reported as `skipped_count`), and findings whose issue was closed or deleted in Jira get a new issue that replaces the link
|
||||
Jira issues created from findings are tracked per provider lifecycle and Jira integration, with concurrency-safe deduplication, recoverable delivery attempts, explicit manual replacement rules, and read-only ledger visibility through `GET /api/v1/jira-issues`
|
||||
|
||||
@@ -63,6 +63,8 @@ class BaseViewSet(ModelViewSet):
|
||||
|
||||
|
||||
class BaseRLSViewSet(BaseViewSet):
|
||||
non_atomic_url_names = frozenset()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.db_alias = self._get_request_db_alias(request)
|
||||
alias_token = None
|
||||
@@ -73,6 +75,12 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
if request is not None:
|
||||
request.db_alias = self.db_alias
|
||||
|
||||
url_name = getattr(
|
||||
getattr(request, "resolver_match", None), "url_name", None
|
||||
)
|
||||
if url_name in self.non_atomic_url_names:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
finally:
|
||||
|
||||
@@ -1920,6 +1920,12 @@ class JiraIssueFilter(BaseProviderFilter):
|
||||
field_name="issue_status_category",
|
||||
lookup_expr="in",
|
||||
)
|
||||
attempt_state = ChoiceFilter(choices=JiraIssue.AttemptStateChoices.choices)
|
||||
attempt_state__in = ChoiceInFilter(
|
||||
choices=JiraIssue.AttemptStateChoices.choices,
|
||||
field_name="attempt_state",
|
||||
lookup_expr="in",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JiraIssue
|
||||
|
||||
@@ -1594,16 +1594,57 @@ class TestLimitedVisibility:
|
||||
def test_jira_issues_limited_to_visible_providers(
|
||||
self, authenticated_client_rbac_limited, jira_issues_fixture
|
||||
):
|
||||
linked, other_provider_issue, _ = jira_issues_fixture
|
||||
linked, other_provider_issue, unlinked = 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)]
|
||||
assert {item["id"] for item in response.json()["data"]} == {
|
||||
str(linked.id),
|
||||
str(unlinked.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_issue_resolution_requires_manage_integrations(
|
||||
self, authenticated_client_no_permissions_rbac, jira_issues_fixture
|
||||
):
|
||||
*_, unlinked = jira_issues_fixture
|
||||
response = authenticated_client_no_permissions_rbac.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": unlinked.id}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {"resolution": "confirm_not_created"},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_jira_issue_resolution_hides_out_of_scope_provider(
|
||||
self, authenticated_client_rbac_limited, jira_issues_fixture
|
||||
):
|
||||
_, other_provider_issue, _ = jira_issues_fixture
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": other_provider_issue.id}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {"resolution": "confirm_not_created"},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_issue_types_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration_fixture
|
||||
):
|
||||
|
||||
@@ -89,6 +89,11 @@ from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from prowler.lib.outputs.jira.models import (
|
||||
JiraIssueSearchMatch,
|
||||
JiraIssueSearchOutcome,
|
||||
JiraIssueSearchResult,
|
||||
)
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
@@ -13558,15 +13563,71 @@ class TestScheduleViewSet:
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestIntegrationJiraViewSet:
|
||||
@pytest.mark.parametrize(
|
||||
("force_replace", "expected"), [(None, False), (True, True)]
|
||||
)
|
||||
def test_dispatch_passes_force_confirmation_and_actor(
|
||||
self,
|
||||
authenticated_client,
|
||||
jira_integration_fixture,
|
||||
findings_fixture,
|
||||
force_replace,
|
||||
expected,
|
||||
):
|
||||
finding, _ = findings_fixture
|
||||
task = Task.objects.create(tenant_id=jira_integration_fixture.tenant_id)
|
||||
attributes = {"project_key": "TEST", "issue_type": "Task"}
|
||||
if force_replace is not None:
|
||||
attributes["force_replace"] = force_replace
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations-jira-dispatches",
|
||||
"attributes": attributes,
|
||||
}
|
||||
}
|
||||
url = reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": jira_integration_fixture.id},
|
||||
)
|
||||
url = f"{url}?filter[finding_id]={finding.id}"
|
||||
|
||||
with patch("api.v1.views.jira_integration_task.delay") as delay:
|
||||
delay.return_value = SimpleNamespace(id=task.id)
|
||||
response = authenticated_client.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
delay.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_replace=expected,
|
||||
actor_id=str(authenticated_client.user.id),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJiraIssueViewSet:
|
||||
def test_list_hides_reservations(self, authenticated_client, jira_issues_fixture):
|
||||
def test_list_returns_linked_and_unlinked_rows_in_stable_order(
|
||||
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
|
||||
expected = sorted(
|
||||
(linked, other_provider_issue, reservation),
|
||||
key=lambda row: (row.finding_uid, str(row.integration_id), str(row.id)),
|
||||
)
|
||||
assert [item["id"] for item in response.json()["data"]] == [
|
||||
str(row.id) for row in expected
|
||||
]
|
||||
|
||||
def test_retrieve(self, authenticated_client, jira_issues_fixture):
|
||||
linked, *_ = jira_issues_fixture
|
||||
@@ -13582,21 +13643,36 @@ class TestJiraIssueViewSet:
|
||||
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_type"] == "Task"
|
||||
assert attributes["issue_status"] == "To Do"
|
||||
assert attributes["issue_status_category"] == "new"
|
||||
assert attributes["status_synced_at"] is not None
|
||||
assert attributes["attempt_state"] == "idle"
|
||||
assert attributes["attempt_operation"] is None
|
||||
assert attributes["attempt_count"] == 0
|
||||
assert "claim_token" not in attributes
|
||||
assert "claim_expires_at" not in attributes
|
||||
assert "delivery_attempt_token" 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(
|
||||
def test_retrieve_unlinked_delivery(
|
||||
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
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
attributes = response.json()["data"]["attributes"]
|
||||
assert attributes["issue_id"] is None
|
||||
assert attributes["issue_key"] is None
|
||||
assert attributes["issue_url"] is None
|
||||
assert attributes["attempt_state"] == "creating"
|
||||
assert attributes["attempt_operation"] == "initial"
|
||||
assert "claim_token" not in attributes
|
||||
assert "delivery_attempt_token" not in attributes
|
||||
|
||||
def test_retrieve_other_tenant_returns_404(
|
||||
self, authenticated_client, jira_issues_fixture, tenants_fixture
|
||||
@@ -13668,6 +13744,14 @@ class TestJiraIssueViewSet:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
|
||||
|
||||
def test_filter_by_attempt_state(self, authenticated_client, jira_issues_fixture):
|
||||
*_, reservation = jira_issues_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"), {"filter[attempt_state]": "creating"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [str(reservation.id)]
|
||||
|
||||
def test_invalid_filter(self, authenticated_client, jira_issues_fixture):
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"), {"filter[invalid]": "x"}
|
||||
@@ -13695,6 +13779,217 @@ class TestJiraIssueViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_resolution_links_issue_verified_by_delivery_marker(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
*_, reservation = jira_issues_fixture
|
||||
with rls_transaction(str(reservation.tenant_id)):
|
||||
JiraIssue.objects.filter(id=reservation.id).update(
|
||||
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
|
||||
claim_token=None,
|
||||
claim_expires_at=None,
|
||||
)
|
||||
jira = MagicMock()
|
||||
lookup = JiraIssueSearchResult(
|
||||
outcome=JiraIssueSearchOutcome.SUCCESS,
|
||||
matches=(
|
||||
JiraIssueSearchMatch(
|
||||
issue_id="20001",
|
||||
issue_key="TEST-9",
|
||||
issue_url="https://test.atlassian.net/browse/TEST-9",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def search_by_marker(_marker):
|
||||
assert connection.in_atomic_block is False
|
||||
return lookup
|
||||
|
||||
jira.search_issues_by_delivery_attempt.side_effect = search_by_marker
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {
|
||||
"resolution": "link",
|
||||
"issue_id": "20001",
|
||||
"issue_key": "TEST-9",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
with (
|
||||
patch("api.v1.views.initialize_prowler_integration", return_value=jira),
|
||||
patch("api.v1.views.logger.info") as audit_log,
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, response.json()
|
||||
assert response["Content-Type"].startswith(API_JSON_CONTENT_TYPE)
|
||||
data = response.json()["data"]
|
||||
assert data["type"] == "jira-issues"
|
||||
assert data["attributes"]["issue_id"] == "20001"
|
||||
assert data["attributes"]["issue_key"] == "TEST-9"
|
||||
assert (
|
||||
data["attributes"]["issue_url"]
|
||||
== "https://test.atlassian.net/browse/TEST-9"
|
||||
)
|
||||
assert data["attributes"]["attempt_state"] == "idle"
|
||||
jira.search_issues_by_delivery_attempt.assert_called_once_with(
|
||||
str(reservation.delivery_attempt_token)
|
||||
)
|
||||
audit_calls = [
|
||||
call
|
||||
for call in audit_log.call_args_list
|
||||
if call.args == ("jira_issue_operator_resolution",)
|
||||
]
|
||||
assert len(audit_calls) == 1
|
||||
log_kwargs = audit_calls[0].kwargs
|
||||
assert log_kwargs["extra"]["user_id"] == str(authenticated_client.user.id)
|
||||
assert log_kwargs["extra"]["metadata"]["resolution"] == "link"
|
||||
assert log_kwargs["extra"]["metadata"]["jira_issue_id"] == str(reservation.id)
|
||||
|
||||
def test_resolution_rejects_issue_not_returned_by_delivery_marker(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
*_, reservation = jira_issues_fixture
|
||||
with rls_transaction(str(reservation.tenant_id)):
|
||||
JiraIssue.objects.filter(id=reservation.id).update(
|
||||
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
|
||||
claim_token=None,
|
||||
claim_expires_at=None,
|
||||
)
|
||||
jira = MagicMock()
|
||||
jira.search_issues_by_delivery_attempt.return_value = JiraIssueSearchResult(
|
||||
outcome=JiraIssueSearchOutcome.SUCCESS,
|
||||
matches=(
|
||||
JiraIssueSearchMatch(
|
||||
issue_id="20002",
|
||||
issue_key="TEST-10",
|
||||
issue_url="https://test.atlassian.net/browse/TEST-10",
|
||||
),
|
||||
),
|
||||
)
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {
|
||||
"resolution": "link",
|
||||
"issue_id": "20001",
|
||||
"issue_key": "TEST-9",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
with patch("api.v1.views.initialize_prowler_integration", return_value=jira):
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
with rls_transaction(str(reservation.tenant_id)):
|
||||
reservation.refresh_from_db()
|
||||
assert reservation.issue_id is None
|
||||
assert reservation.attempt_state == JiraIssue.AttemptStateChoices.UNCERTAIN
|
||||
|
||||
def test_resolution_confirm_not_created_preserves_previous_link(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
linked, *_ = jira_issues_fixture
|
||||
with rls_transaction(str(linked.tenant_id)):
|
||||
JiraIssue.objects.filter(id=linked.id).update(
|
||||
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
|
||||
delivery_attempt_token=uuid4(),
|
||||
attempt_operation=JiraIssue.AttemptOperationChoices.REPLACEMENT,
|
||||
attempt_project_key="TEST",
|
||||
attempt_issue_type="Task",
|
||||
next_reconcile_at=datetime.now(UTC) + timedelta(minutes=1),
|
||||
)
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {"resolution": "confirm_not_created"},
|
||||
}
|
||||
}
|
||||
|
||||
with patch("api.v1.views.logger.info") as audit_log:
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": linked.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
attributes = response.json()["data"]["attributes"]
|
||||
assert attributes["issue_id"] == "10001"
|
||||
assert attributes["issue_key"] == "TEST-1"
|
||||
assert attributes["attempt_state"] == "retryable_failure"
|
||||
assert attributes["last_error_code"] == "operator_confirmed_not_created"
|
||||
assert attributes["next_reconcile_at"] is None
|
||||
audit_calls = [
|
||||
call
|
||||
for call in audit_log.call_args_list
|
||||
if call.args == ("jira_issue_operator_resolution",)
|
||||
]
|
||||
assert len(audit_calls) == 1
|
||||
assert audit_calls[0].kwargs["extra"]["metadata"]["resolution"] == (
|
||||
"confirm_not_created"
|
||||
)
|
||||
|
||||
def test_resolution_rejects_fresh_foreign_claim(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
*_, reservation = jira_issues_fixture
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {"resolution": "confirm_not_created"},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
def test_resolution_confirm_not_created_rejects_issue_fields(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
*_, reservation = jira_issues_fixture
|
||||
with rls_transaction(str(reservation.tenant_id)):
|
||||
JiraIssue.objects.filter(id=reservation.id).update(
|
||||
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
|
||||
claim_token=None,
|
||||
claim_expires_at=None,
|
||||
)
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {
|
||||
"resolution": "confirm_not_created",
|
||||
"issue_id": "20001",
|
||||
"issue_key": "TEST-9",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestIntegrationViewSet:
|
||||
|
||||
@@ -3226,6 +3226,7 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1):
|
||||
|
||||
project_key = serializers.CharField(required=True)
|
||||
issue_type = serializers.CharField(required=True)
|
||||
force_replace = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "integrations-jira-dispatches"
|
||||
@@ -4160,11 +4161,11 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
|
||||
|
||||
class JiraIssueSerializer(RLSSerializer):
|
||||
"""
|
||||
Read-only view of a Jira issue linked to a finding by a Jira integration.
|
||||
Read-only view of a Jira delivery ledger row for a finding.
|
||||
|
||||
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.
|
||||
Rows are keyed on the finding ``uid`` so delivery state survives scans.
|
||||
Current-link fields stay null until Jira creation is confirmed.
|
||||
``issue_status`` is cached rather than fetched when this resource is read.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -4179,9 +4180,17 @@ class JiraIssueSerializer(RLSSerializer):
|
||||
"issue_id",
|
||||
"issue_url",
|
||||
"project_key",
|
||||
"issue_type",
|
||||
"issue_status",
|
||||
"issue_status_category",
|
||||
"status_synced_at",
|
||||
"attempt_state",
|
||||
"attempt_operation",
|
||||
"attempt_count",
|
||||
"last_attempt_at",
|
||||
"last_error_code",
|
||||
"last_error_message",
|
||||
"next_reconcile_at",
|
||||
"integration",
|
||||
"provider",
|
||||
"url",
|
||||
@@ -4193,6 +4202,43 @@ class JiraIssueSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
|
||||
class JiraIssueResolutionSerializer(BaseSerializerV1):
|
||||
"""Validate an operator decision for an uncertain Jira delivery."""
|
||||
|
||||
resolution = serializers.ChoiceField(
|
||||
choices=("link", "confirm_not_created"), required=True
|
||||
)
|
||||
issue_id = serializers.CharField(required=False, allow_blank=False, max_length=64)
|
||||
issue_key = serializers.CharField(required=False, allow_blank=False, max_length=64)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "jira-issues"
|
||||
|
||||
def validate(self, attrs):
|
||||
resolution = attrs["resolution"]
|
||||
issue_id = attrs.get("issue_id")
|
||||
issue_key = attrs.get("issue_key")
|
||||
|
||||
if resolution == "link":
|
||||
missing_fields = {
|
||||
field: "This field is required when linking a Jira issue."
|
||||
for field, value in (("issue_id", issue_id), ("issue_key", issue_key))
|
||||
if not value
|
||||
}
|
||||
if missing_fields:
|
||||
raise ValidationError(missing_fields)
|
||||
if resolution == "confirm_not_created" and (issue_id or issue_key):
|
||||
raise ValidationError(
|
||||
{
|
||||
"resolution": (
|
||||
"Issue fields are not accepted when confirming that Jira did "
|
||||
"not create an issue."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class MuteRuleSerializer(RLSSerializer):
|
||||
"""
|
||||
Serializer for reading MuteRule instances.
|
||||
|
||||
+208
-15
@@ -32,6 +32,7 @@ from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import (
|
||||
ComplianceWarmingError,
|
||||
ConflictException,
|
||||
TaskFailedException,
|
||||
UpstreamAccessDeniedError,
|
||||
UpstreamAuthenticationError,
|
||||
@@ -181,6 +182,7 @@ from api.v1.serializers import (
|
||||
InvitationCreateSerializer,
|
||||
InvitationSerializer,
|
||||
InvitationUpdateSerializer,
|
||||
JiraIssueResolutionSerializer,
|
||||
JiraIssueSerializer,
|
||||
LighthouseConfigCreateSerializer,
|
||||
LighthouseConfigSerializer,
|
||||
@@ -260,7 +262,7 @@ from django.conf import settings as django_settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
Case,
|
||||
@@ -303,6 +305,10 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema
|
||||
from prowler.lib.outputs.jira.models import (
|
||||
JiraIssueSearchOutcome,
|
||||
JiraIssueSearchResult,
|
||||
)
|
||||
from prowler.providers.aws.exceptions.exceptions import (
|
||||
AWSAssumeRoleError,
|
||||
AWSCredentialsError,
|
||||
@@ -7004,6 +7010,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
|
||||
project_key = serializer.validated_data["project_key"]
|
||||
issue_type = serializer.validated_data["issue_type"]
|
||||
force_replace = serializer.validated_data["force_replace"]
|
||||
|
||||
with transaction.atomic():
|
||||
task = jira_integration_task.delay(
|
||||
@@ -7012,6 +7019,8 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
finding_ids=finding_ids,
|
||||
force_replace=force_replace,
|
||||
actor_id=str(request.user.id),
|
||||
)
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
serializer = TaskSerializer(prowler_task)
|
||||
@@ -7493,51 +7502,235 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="List Jira issues linked to findings",
|
||||
summary="List Jira issue delivery records",
|
||||
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 Jira delivery records for findings, including attempts without a "
|
||||
"confirmed Jira issue. Linked records include the last status observed in "
|
||||
"Jira; status fields are cached and may be stale."
|
||||
),
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Retrieve a Jira issue link",
|
||||
description="Fetch the Jira issue linked to a finding by the link ID.",
|
||||
summary="Retrieve a Jira issue delivery record",
|
||||
description="Fetch a Jira delivery record by its ledger ID.",
|
||||
),
|
||||
resolution=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Resolve an uncertain Jira delivery",
|
||||
description=(
|
||||
"Link an issue verified through the delivery marker, or confirm that Jira "
|
||||
"did not create an issue so the finding can be retried."
|
||||
),
|
||||
request=JiraIssueResolutionSerializer,
|
||||
responses={200: JiraIssueSerializer},
|
||||
),
|
||||
)
|
||||
class JiraIssueViewSet(BaseRLSViewSet):
|
||||
queryset = JiraIssue.objects.all()
|
||||
serializer_class = JiraIssueSerializer
|
||||
filterset_class = JiraIssueFilter
|
||||
http_method_names = ["get"]
|
||||
http_method_names = ["get", "post"]
|
||||
search_fields = ["finding_uid", "issue_key"]
|
||||
ordering = ["-inserted_at"]
|
||||
ordering = ["finding_uid", "integration_id", "id"]
|
||||
ordering_fields = [
|
||||
"id",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"finding_uid",
|
||||
"finding_id",
|
||||
"integration",
|
||||
"provider",
|
||||
"issue_key",
|
||||
"project_key",
|
||||
"issue_status",
|
||||
"issue_status_category",
|
||||
"status_synced_at",
|
||||
"attempt_state",
|
||||
"next_reconcile_at",
|
||||
]
|
||||
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enables unlimited
|
||||
# visibility or check visibility via provider group, like findings)
|
||||
required_permissions = []
|
||||
# Jira verification must not hold a database transaction open during HTTP I/O.
|
||||
non_atomic_url_names = frozenset({"jiraissue-resolution"})
|
||||
|
||||
def set_required_permissions(self):
|
||||
self.required_permissions = (
|
||||
[Permissions.MANAGE_INTEGRATIONS] if self.action == "resolution" else []
|
||||
)
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def create(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="POST")
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, "swagger_fake_view", False):
|
||||
return JiraIssue.objects.none()
|
||||
# Rows without an issue key are in-flight reservations, not links
|
||||
queryset = JiraIssue.objects.filter(tenant_id=self.request.tenant_id).exclude(
|
||||
issue_key=""
|
||||
)
|
||||
queryset = JiraIssue.objects.filter(tenant_id=self.request.tenant_id)
|
||||
if not self.user_role.unlimited_visibility:
|
||||
queryset = queryset.filter(provider__in=get_providers(self.user_role))
|
||||
return queryset.select_related("provider", "integration")
|
||||
|
||||
@staticmethod
|
||||
def _resolution_queryset(instance):
|
||||
return JiraIssue.objects.filter(
|
||||
id=instance.id,
|
||||
tenant_id=instance.tenant_id,
|
||||
attempt_state=instance.attempt_state,
|
||||
delivery_attempt_token=instance.delivery_attempt_token,
|
||||
issue_id=instance.issue_id,
|
||||
claim_token=instance.claim_token,
|
||||
claim_expires_at=instance.claim_expires_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_resolution_state(instance):
|
||||
if (
|
||||
instance.attempt_state
|
||||
not in {
|
||||
JiraIssue.AttemptStateChoices.CREATING,
|
||||
JiraIssue.AttemptStateChoices.UNCERTAIN,
|
||||
}
|
||||
or instance.delivery_attempt_token is None
|
||||
):
|
||||
raise ConflictException(
|
||||
detail="This Jira delivery does not require operator resolution."
|
||||
)
|
||||
if (
|
||||
instance.attempt_state == JiraIssue.AttemptStateChoices.CREATING
|
||||
and instance.claim_token
|
||||
and instance.claim_expires_at
|
||||
and instance.claim_expires_at > datetime.now(UTC)
|
||||
):
|
||||
raise ConflictException(
|
||||
detail="Another Jira delivery task still owns this attempt."
|
||||
)
|
||||
|
||||
def _log_resolution(self, instance, resolution, old_issue):
|
||||
logger.info(
|
||||
"jira_issue_operator_resolution",
|
||||
extra={
|
||||
"user_id": str(self.request.user.id),
|
||||
"tenant_id": str(instance.tenant_id),
|
||||
"metadata": {
|
||||
"resolution": resolution,
|
||||
"jira_issue_id": str(instance.id),
|
||||
"integration_id": str(instance.integration_id),
|
||||
"provider_id": str(instance.provider_id),
|
||||
"finding_uid": instance.finding_uid,
|
||||
"old_issue_id": old_issue[0],
|
||||
"old_issue_key": old_issue[1],
|
||||
"resolved_issue_id": instance.issue_id,
|
||||
"resolved_issue_key": instance.issue_key,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="resolution")
|
||||
def resolution(self, request, pk=None):
|
||||
with rls_transaction(str(self.request.tenant_id), using=MainRouter.default_db):
|
||||
instance = self.get_object()
|
||||
self._validate_resolution_state(instance)
|
||||
serializer = JiraIssueResolutionSerializer(
|
||||
data=request.data, context=self.get_serializer_context()
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
resolution = serializer.validated_data["resolution"]
|
||||
old_issue = (instance.issue_id, instance.issue_key)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if resolution == "link":
|
||||
try:
|
||||
jira = initialize_prowler_integration(instance.integration)
|
||||
lookup = jira.search_issues_by_delivery_attempt(
|
||||
str(instance.delivery_attempt_token)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira marker lookup failed during operator resolution for %s",
|
||||
instance.id,
|
||||
)
|
||||
raise ValidationError(
|
||||
{"resolution": "Jira could not verify the selected issue."}
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(lookup, JiraIssueSearchResult)
|
||||
or lookup.outcome != JiraIssueSearchOutcome.SUCCESS
|
||||
or not isinstance(lookup.matches, tuple)
|
||||
):
|
||||
raise ValidationError(
|
||||
{"resolution": "Jira could not verify the selected issue."}
|
||||
)
|
||||
|
||||
issue_id = serializer.validated_data["issue_id"]
|
||||
issue_key = serializer.validated_data["issue_key"]
|
||||
matches = [
|
||||
match
|
||||
for match in lookup.matches
|
||||
if getattr(match, "issue_id", None) == issue_id
|
||||
and getattr(match, "issue_key", None) == issue_key
|
||||
]
|
||||
if len(matches) != 1 or not getattr(matches[0], "issue_url", None):
|
||||
raise ValidationError(
|
||||
{
|
||||
"issue_id": (
|
||||
"The selected issue does not match this delivery attempt."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
match = matches[0]
|
||||
updates = {
|
||||
"issue_id": match.issue_id[:64],
|
||||
"issue_key": match.issue_key[:64],
|
||||
"issue_url": match.issue_url[:2048],
|
||||
"project_key": instance.attempt_project_key,
|
||||
"issue_type": instance.attempt_issue_type,
|
||||
"issue_status": None,
|
||||
"issue_status_category": None,
|
||||
"status_synced_at": None,
|
||||
"attempt_state": JiraIssue.AttemptStateChoices.IDLE,
|
||||
"claim_token": None,
|
||||
"claim_expires_at": None,
|
||||
"last_error_code": None,
|
||||
"last_error_message": None,
|
||||
"next_reconcile_at": None,
|
||||
"updated_at": now,
|
||||
}
|
||||
else:
|
||||
updates = {
|
||||
"attempt_state": JiraIssue.AttemptStateChoices.RETRYABLE_FAILURE,
|
||||
"claim_token": None,
|
||||
"claim_expires_at": None,
|
||||
"last_error_code": "operator_confirmed_not_created",
|
||||
"last_error_message": (
|
||||
"An operator confirmed that Jira did not create the issue."
|
||||
),
|
||||
"next_reconcile_at": None,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
try:
|
||||
with rls_transaction(str(instance.tenant_id), using=MainRouter.default_db):
|
||||
updated = self._resolution_queryset(instance).update(**updates)
|
||||
except IntegrityError as error:
|
||||
raise ConflictException(
|
||||
detail="The selected Jira issue is already linked."
|
||||
) from error
|
||||
if not updated:
|
||||
raise ConflictException(
|
||||
detail="The Jira delivery state changed during resolution."
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(instance, field, value)
|
||||
self._log_resolution(instance, resolution, old_issue)
|
||||
response_serializer = JiraIssueSerializer(
|
||||
instance, context=self.get_serializer_context()
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# MuteRules
|
||||
@extend_schema_view(
|
||||
|
||||
@@ -1475,7 +1475,7 @@ 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."""
|
||||
"""Two linked issues (one per provider) and one in-flight delivery."""
|
||||
provider, provider2 = aws_provider_pair
|
||||
finding1, finding2 = findings_fixture
|
||||
tenant_id = jira_integration_fixture.tenant_id
|
||||
|
||||
@@ -1333,6 +1333,7 @@ def _process_jira_delivery_page(
|
||||
finding_ids: list[str],
|
||||
claim_token: str,
|
||||
force_replace: bool,
|
||||
actor_id: str | None,
|
||||
) -> list[dict]:
|
||||
findings, ledger = _load_jira_delivery_page(tenant_id, integration_id, finding_ids)
|
||||
linked_idle_rows = [
|
||||
@@ -1501,6 +1502,22 @@ def _process_jira_delivery_page(
|
||||
if claimed is None:
|
||||
results.append(_deferred_result(finding, row, "claim_conflict"))
|
||||
else:
|
||||
if unknown_status and force_replace:
|
||||
logger.warning(
|
||||
"jira_force_replacement",
|
||||
extra={
|
||||
"user_id": actor_id,
|
||||
"tenant_id": str(tenant_id),
|
||||
"metadata": {
|
||||
"integration_id": str(integration_id),
|
||||
"provider_id": str(finding.scan.provider_id),
|
||||
"finding_uid": finding.uid,
|
||||
"old_issue_id": row.issue_id,
|
||||
"old_issue_key": row.issue_key,
|
||||
"old_issue_url": row.issue_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
results.append(
|
||||
_send_claimed_finding(
|
||||
tenant_id,
|
||||
@@ -1548,6 +1565,7 @@ def send_findings_to_jira(
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
force_replace: bool = False,
|
||||
actor_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Deliver findings through the concurrency-safe Jira issue ledger."""
|
||||
claim_token = str(task_id or uuid4())
|
||||
@@ -1575,6 +1593,7 @@ def send_findings_to_jira(
|
||||
page,
|
||||
claim_token,
|
||||
force_replace,
|
||||
actor_id,
|
||||
)
|
||||
for result in page_results:
|
||||
_record_delivery_result(summary, result)
|
||||
|
||||
@@ -1395,6 +1395,7 @@ def jira_integration_task(
|
||||
issue_type: str,
|
||||
finding_ids: list[str],
|
||||
force_replace: bool = False,
|
||||
actor_id: str | None = None,
|
||||
):
|
||||
return send_findings_to_jira(
|
||||
tenant_id,
|
||||
@@ -1404,6 +1405,7 @@ def jira_integration_task(
|
||||
finding_ids,
|
||||
task_id=self.request.id,
|
||||
force_replace=force_replace,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1798,6 +1798,7 @@ class TestSafeJiraDelivery:
|
||||
*,
|
||||
task_id="jira-task-1",
|
||||
force_replace=False,
|
||||
actor_id=None,
|
||||
):
|
||||
with patch(
|
||||
"tasks.jobs.integrations.initialize_prowler_integration",
|
||||
@@ -1811,6 +1812,7 @@ class TestSafeJiraDelivery:
|
||||
[str(finding_id) for finding_id in finding_ids],
|
||||
task_id=task_id,
|
||||
force_replace=force_replace,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -2082,17 +2084,33 @@ class TestSafeJiraDelivery:
|
||||
assert skipped["skipped_count"] == 1
|
||||
jira_mock.send_finding.assert_not_called()
|
||||
|
||||
forced = self._send(
|
||||
jira_integration_fixture,
|
||||
jira_mock,
|
||||
[finding.id],
|
||||
task_id="jira-task-3",
|
||||
force_replace=True,
|
||||
)
|
||||
with patch("tasks.jobs.integrations.logger.warning") as warning:
|
||||
forced = self._send(
|
||||
jira_integration_fixture,
|
||||
jira_mock,
|
||||
[finding.id],
|
||||
task_id="jira-task-3",
|
||||
force_replace=True,
|
||||
actor_id="operator-1",
|
||||
)
|
||||
assert forced["created_count"] == 1
|
||||
row = self._row(jira_integration_fixture, finding)
|
||||
assert row.issue_key == "TEST-2"
|
||||
assert row.delivery_attempt_token != original_marker
|
||||
warning.assert_called_once()
|
||||
_, log_kwargs = warning.call_args
|
||||
assert log_kwargs["extra"]["user_id"] == "operator-1"
|
||||
assert log_kwargs["extra"]["tenant_id"] == str(
|
||||
jira_integration_fixture.tenant_id
|
||||
)
|
||||
assert log_kwargs["extra"]["metadata"] == {
|
||||
"integration_id": str(jira_integration_fixture.id),
|
||||
"provider_id": str(finding.scan.provider_id),
|
||||
"finding_uid": finding.uid,
|
||||
"old_issue_id": original.issue_id,
|
||||
"old_issue_key": original.issue_key,
|
||||
"old_issue_url": original.issue_url,
|
||||
}
|
||||
|
||||
def test_retryable_failure_reuses_delivery_marker(
|
||||
self, jira_mock, jira_integration_fixture, findings_fixture
|
||||
|
||||
@@ -138,12 +138,51 @@ Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJA
|
||||
|
||||
### Sending a Finding That Already Has a Jira Issue
|
||||
|
||||
Prowler remembers the Jira issue created for each Finding, keyed by the Finding UID, so sending the same Finding again does not create a duplicate issue:
|
||||
Prowler keeps one Jira delivery record for each Finding UID, Provider, and Jira integration. Sending the same Finding again follows the current Jira state:
|
||||
|
||||
* If the linked issue is still open in Jira, the Finding is skipped and the existing issue key is reported in the task result (`skipped_count`, `skipped`).
|
||||
* If the linked issue is closed (any Jira status in the **Done** category) or was deleted, a new issue is created and becomes the linked issue for that Finding.
|
||||
* If the linked issue is open, Prowler refreshes its cached status and skips the Finding.
|
||||
* If Jira moved or renamed the issue, Prowler updates the key and URL after confirming the immutable Jira issue ID, then skips the Finding.
|
||||
* If the linked issue is in the **Done** category, Prowler creates one replacement. The previous link remains available until Jira confirms the replacement.
|
||||
* If Jira reports the issue as missing or forbidden, or cannot determine its state, Prowler preserves the existing link and skips the Finding. API clients can send `force_replace: true` to confirm the duplicate risk and request one replacement. This option never replaces an issue that Jira confirms is open.
|
||||
* If Jira cannot confirm whether a create request succeeded, Prowler records the attempt as `uncertain` and searches Jira by the persisted delivery marker before any retry. It does not send another create request blindly.
|
||||
|
||||
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` or `issue_key`). Each Jira integration keeps its own links, so the same Finding can have one issue per integration.
|
||||
`GET /api/v1/jira-issues` returns both linked and unlinked delivery records. It exposes the current link, attempt state, safe delivery error, and next reconciliation time. It does not expose claim tokens or delivery markers. Filter by `finding_uid`, `finding_uid__in`, `finding_id`, `provider_id`, `integration`, `issue_key`, `issue_status_category`, or `attempt_state`.
|
||||
|
||||
The Jira status in this response is the last status Prowler observed during a dispatch. It is cached and may be stale between dispatches. Each Jira integration keeps its own delivery record, so the same Finding can have one issue per integration.
|
||||
|
||||
### Recovering an Uncertain Jira Delivery
|
||||
|
||||
Users with the **Manage Integrations** permission can resolve an uncertain delivery through `POST /api/v1/jira-issues/{id}/resolution`. Provider visibility still applies.
|
||||
|
||||
Use `resolution: "link"` with the Jira issue ID and key when Jira created the issue. Prowler searches by the persisted delivery marker and links only an issue returned by Jira:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {
|
||||
"resolution": "link",
|
||||
"issue_id": "10001",
|
||||
"issue_key": "SEC-42"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `resolution: "confirm_not_created"` without issue fields after confirming that Jira did not create the issue. Prowler clears the claim, preserves any previous link, and marks the attempt as retryable:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "jira-issues",
|
||||
"attributes": {
|
||||
"resolution": "confirm_not_created"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Prowler rejects operator resolution while a delivery task still owns a valid claim.
|
||||
|
||||
## Integration Status
|
||||
|
||||
@@ -162,7 +201,7 @@ Each Jira integration provides management actions through dedicated buttons:
|
||||
| Button | Purpose | Available Actions | Notes |
|
||||
|--------|---------|------------------|-------|
|
||||
| **Test** | Verify integration connectivity | • Test Jira API access<br/>• Validate credentials<br/>• Check project permissions<br/>• Verify work item creation capability | Results displayed in notification message |
|
||||
| **Credentials** | Update authentication settings | • Change API token<br/>• Update email<br/>• Update Jira domain | Click "Update Credentials" to save changes |
|
||||
| **Credentials** | Update authentication settings | • Change API token<br/>• Update email | The Jira domain identifies the integration and cannot be changed after creation |
|
||||
| **Enable/Disable** | Toggle integration status | • Enable or disable integration<br/>| Status change takes effect immediately |
|
||||
| **Delete** | Remove integration permanently | • Permanently delete integration<br/>• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting |
|
||||
|
||||
@@ -296,5 +335,10 @@ If you don't have `jq` installed, run the command without `| jq`.
|
||||
|
||||
How to read it:
|
||||
|
||||
* "created_count": number of Jira issues successfully created.
|
||||
* "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.
|
||||
* `created_count`: Jira issues that were confirmed and linked.
|
||||
* `skipped_count`: Findings that kept an existing Jira issue.
|
||||
* `deferred_count`: Findings currently owned by another delivery task.
|
||||
* `uncertain_count`: Attempts that need delivery-marker reconciliation or operator recovery.
|
||||
* `failed_count`: Attempts that Jira rejected or that can be retried later.
|
||||
|
||||
The `results` list contains safe per-Finding details and is capped at 100 entries. The counters remain exact when `truncated` is `true`.
|
||||
|
||||
Reference in New Issue
Block a user