From 53be73c2acb2cfa700e6831975a51f4478a9d763 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero Date: Tue, 25 Aug 2026 16:29:19 +0200 Subject: [PATCH] feat(api): add finding labels, finding URL and tenant info to Jira issues send_findings_to_jira() now passes issue_labels (prowler, prowler-{provider}, prowler-{severity}, prowler-{check_id} and prowler-finding-{sanitized uid}), a finding_url built from the new DJANGO_UI_BASE_URL setting (empty by default, so self-hosted deployments emit no link unless configured) and the tenant name as tenant_info. The label sanitizer is local to the API so it does not depend on an unreleased SDK symbol. --- api/.env.example | 4 + .../jira-finding-labels-url.added.md | 1 + api/src/backend/config/django/base.py | 5 + api/src/backend/tasks/jobs/integrations.py | 98 +++++++++++++- .../backend/tasks/tests/test_integrations.py | 120 +++++++++++++++++- .../prowler-app-jira-integration.mdx | 10 ++ 6 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 api/changelog.d/jira-finding-labels-url.added.md diff --git a/api/.env.example b/api/.env.example index f97d868890..c9300ab6b9 100644 --- a/api/.env.example +++ b/api/.env.example @@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID="" DJANGO_GITHUB_OAUTH_CLIENT_SECRET="" DJANGO_GITHUB_OAUTH_CALLBACK_URL="" +# Public base URL of the Prowler UI, used to link Jira issues back to findings. +# Leave empty to omit the link. +DJANGO_UI_BASE_URL="" + # Deletion Task Batch Size DJANGO_DELETION_BATCH_SIZE=5000 diff --git a/api/changelog.d/jira-finding-labels-url.added.md b/api/changelog.d/jira-finding-labels-url.added.md new file mode 100644 index 0000000000..99e43612c9 --- /dev/null +++ b/api/changelog.d/jira-finding-labels-url.added.md @@ -0,0 +1 @@ +Jira issues created from Prowler Cloud now carry `prowler-*` labels (provider, severity, check id and a sanitized finding UID), a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index a079942600..b967110c10 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -303,6 +303,11 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000) +# Public base URL of the Prowler UI (for example https://cloud.prowler.com). Used to +# build links back to findings in outbound integrations such as Jira. Empty by +# default, so self-hosted deployments emit no links unless they configure it. +UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/") + # SAML requirement CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 303d28c139..d8066d34be 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -2,13 +2,16 @@ import os import time from datetime import UTC, datetime from glob import glob +from urllib.parse import quote 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.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 prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.generic.generic import GenericCompliance @@ -477,6 +480,81 @@ def upload_security_hub_integration( return False +JIRA_LABEL_PREFIX = "prowler" +JIRA_LABEL_MAX_LENGTH = 255 + + +def sanitize_jira_label(label: str) -> str: + """Make a value safe to use as a Jira label. + + Jira rejects labels containing whitespace or longer than 255 characters. The + transformation is deterministic so the same finding always yields the same + label: whitespace runs become a single underscore, control characters are + dropped and the result is truncated. Mirrors ``Jira.sanitize_label`` in the + SDK; kept local so the API does not depend on an unreleased SDK symbol. + """ + if not label: + return "" + cleaned = "".join(ch for ch in str(label) if ch.isprintable() or ch.isspace()) + return "_".join(cleaned.split())[:JIRA_LABEL_MAX_LENGTH] + + +def sanitize_jira_labels(labels: list[str]) -> list[str]: + """Sanitize a list of labels, dropping empties and duplicates (order kept).""" + result: list[str] = [] + for label in labels or []: + sanitized = sanitize_jira_label(label) + if sanitized and sanitized not in result: + result.append(sanitized) + return result + + +def build_jira_finding_url(finding_uid: str) -> str: + """Build the Prowler UI link for a finding, or "" when no UI base URL is set. + + The link filters by the finding ``uid`` rather than the per-scan record id so + it keeps resolving after the finding is seen again in later scans. + """ + base_url = getattr(settings, "UI_BASE_URL", "") + if not base_url or not finding_uid: + return "" + return f"{base_url}/findings?filter[uid]={quote(finding_uid, safe='')}" + + +def build_jira_issue_labels( + finding_uid: str, provider: str, severity: str, check_id: str +) -> list[str]: + """Build the deterministic label set written to every Jira issue. + + Labels are prefixed to avoid colliding with customer labels and sanitized so + Jira never rejects them; the finding-uid label is what lets a ticket be traced + back (or JQL-filtered) to its finding. + """ + raw_labels = [ + JIRA_LABEL_PREFIX, + f"{JIRA_LABEL_PREFIX}-{provider}" if provider else "", + f"{JIRA_LABEL_PREFIX}-{severity}" if severity else "", + f"{JIRA_LABEL_PREFIX}-{check_id}" if check_id else "", + f"{JIRA_LABEL_PREFIX}-finding-{finding_uid}" if finding_uid else "", + ] + return sanitize_jira_labels(raw_labels) + + +def get_tenant_name(tenant_id: str) -> str: + """Return the tenant name for the Jira issue "Tenant Info" row, or "" if unknown. + + The name is informational only, so a lookup failure must never block the send. + """ + try: + return ( + Tenant.objects.filter(id=tenant_id).values_list("name", flat=True).first() + or "" + ) + except Exception: + logger.warning("Could not resolve tenant name for %s", tenant_id) + return "" + + def send_findings_to_jira( tenant_id: str, integration_id: str, @@ -487,6 +565,7 @@ def send_findings_to_jira( with rls_transaction(tenant_id): integration = Integration.objects.get(id=integration_id) jira_integration = initialize_prowler_integration(integration) + tenant_info = get_tenant_name(tenant_id) num_tickets_created = 0 error_messages = [] @@ -519,6 +598,15 @@ def send_findings_to_jira( 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( @@ -527,7 +615,7 @@ def send_findings_to_jira( severity=finding_instance.severity, status=finding_instance.status, status_extended=finding_instance.status_extended or "", - provider=finding_instance.scan.provider.provider, + provider=provider_type, region=region, resource_uid=resource_uid, resource_name=resource_name, @@ -542,6 +630,9 @@ def send_findings_to_jira( 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 @@ -557,6 +648,11 @@ def send_findings_to_jira( if result: num_tickets_created += 1 + logger.info( + "Finding %s sent to Jira as %s", + finding_id, + result.get("key") if isinstance(result, dict) else result, + ) else: error_message = JIRA_GENERIC_SEND_ERROR logger.error(error_message) diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index a95d02fa7f..2a66aa45a2 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -6,6 +6,7 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.models import Integration from api.utils import prowler_integration_connection_test from django.db import OperationalError +from django.test import override_settings from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraRefreshTokenError, JiraRequiredCustomFieldsError, @@ -13,8 +14,13 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection from prowler.providers.common.models import Connection from tasks.jobs.integrations import ( + build_jira_finding_url, + build_jira_issue_labels, get_s3_client_from_integration, get_security_hub_client_from_integration, + get_tenant_name, + sanitize_jira_label, + sanitize_jira_labels, send_findings_to_jira, upload_s3_integration, upload_security_hub_integration, @@ -1696,6 +1702,7 @@ class TestJiraIntegration: finding1 = MagicMock() finding1.id = "finding-1" + finding1.uid = "prowler-aws-check_001-123456789012-us-east-1-my bucket" finding1.check_id = "check_001" finding1.severity = "high" finding1.status = "FAIL" @@ -1724,6 +1731,7 @@ class TestJiraIntegration: finding2 = MagicMock() finding2.id = "finding-2" + finding2.uid = "prowler-azure-check_002-sub/resource" finding2.check_id = "check_002" finding2.severity = "medium" finding2.status = "PASS" @@ -1748,9 +1756,13 @@ class TestJiraIntegration: ] # Call the function - result = send_findings_to_jira( - tenant_id, integration_id, project_key, issue_type, finding_ids - ) + with ( + override_settings(UI_BASE_URL="https://cloud.example.com"), + patch("tasks.jobs.integrations.get_tenant_name", return_value="Acme"), + ): + result = send_findings_to_jira( + tenant_id, integration_id, project_key, issue_type, finding_ids + ) # Assertions assert result == {"created_count": 2, "failed_count": 0} @@ -1773,12 +1785,36 @@ class TestJiraIntegration: assert first_call.kwargs["provider"] == "aws" assert first_call.kwargs["project_key"] == project_key assert first_call.kwargs["issue_type"] == issue_type + # Finding reference: labels, link back and tenant info + assert first_call.kwargs["issue_labels"] == [ + "prowler", + "prowler-aws", + "prowler-high", + "prowler-check_001", + "prowler-finding-prowler-aws-check_001-123456789012-us-east-1-my_bucket", + ] + assert first_call.kwargs["finding_url"] == ( + "https://cloud.example.com/findings?filter[uid]=" + "prowler-aws-check_001-123456789012-us-east-1-my%20bucket" + ) + assert first_call.kwargs["tenant_info"] == "Acme" # Verify second call second_call = mock_jira_integration.send_finding.call_args_list[1] assert second_call.kwargs["check_id"] == "check_002" assert second_call.kwargs["severity"] == "medium" assert second_call.kwargs["status"] == "PASS" + assert second_call.kwargs["issue_labels"] == [ + "prowler", + "prowler-azure", + "prowler-medium", + "prowler-check_002", + "prowler-finding-prowler-azure-check_002-sub/resource", + ] + assert second_call.kwargs["finding_url"] == ( + "https://cloud.example.com/findings?filter[uid]=" + "prowler-azure-check_002-sub%2Fresource" + ) @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.Finding") @@ -2200,3 +2236,81 @@ class TestJiraIntegration: assert call_kwargs["remediation_code_cli"] == "" assert call_kwargs["remediation_code_other"] == "" assert call_kwargs["compliance"] == {} + + +class TestJiraFindingReference: + """Helpers that give Jira issues a stable reference back to the finding.""" + + def test_sanitize_jira_label(self): + assert sanitize_jira_label("") == "" + assert sanitize_jira_label(None) == "" + assert sanitize_jira_label(" ") == "" + assert sanitize_jira_label("simple") == "simple" + assert sanitize_jira_label("with space") == "with_space" + assert sanitize_jira_label(" many spaces \t tabs\nnewline ") == ( + "many_spaces_tabs_newline" + ) + assert sanitize_jira_label("ctrl\x00char\x07here") == "ctrlcharhere" + assert sanitize_jira_label("arn:aws:iam::123456789012:role/Admin") == ( + "arn:aws:iam::123456789012:role/Admin" + ) + assert sanitize_jira_label("x" * 300) == "x" * 255 + # Deterministic and idempotent + once = sanitize_jira_label("a b\tc") + assert sanitize_jira_label(once) == once + + def test_sanitize_jira_labels(self): + assert sanitize_jira_labels([]) == [] + assert sanitize_jira_labels(None) == [] + assert sanitize_jira_labels(["b", "a b", "b", "", "a_b"]) == ["b", "a_b"] + + def test_build_jira_issue_labels(self): + assert build_jira_issue_labels( + finding_uid="prowler-aws-check-123-eu-west-1-hub/unknown", + provider="aws", + severity="critical", + check_id="iam_root_mfa", + ) == [ + "prowler", + "prowler-aws", + "prowler-critical", + "prowler-iam_root_mfa", + "prowler-finding-prowler-aws-check-123-eu-west-1-hub/unknown", + ] + + def test_build_jira_issue_labels_skips_empty_parts(self): + assert build_jira_issue_labels( + finding_uid="", provider="", severity="", check_id="" + ) == ["prowler"] + + def test_build_jira_issue_labels_truncates_long_uid(self): + labels = build_jira_issue_labels( + finding_uid="u" * 300, provider="gcp", severity="low", check_id="c" + ) + assert labels[-1] == ("prowler-finding-" + "u" * 300)[:255] + assert all(len(label) <= 255 for label in labels) + + @override_settings(UI_BASE_URL="") + def test_build_jira_finding_url_without_base_url(self): + assert build_jira_finding_url("prowler-aws-check-1") == "" + + @override_settings(UI_BASE_URL="https://cloud.example.com") + def test_build_jira_finding_url_with_base_url(self): + assert build_jira_finding_url("prowler-aws-check-1") == ( + "https://cloud.example.com/findings?filter[uid]=prowler-aws-check-1" + ) + # uid characters that would break the query string are encoded + assert build_jira_finding_url("a/b c&d") == ( + "https://cloud.example.com/findings?filter[uid]=a%2Fb%20c%26d" + ) + assert build_jira_finding_url("") == "" + + @pytest.mark.django_db + def test_get_tenant_name(self, tenants_fixture): + tenant = tenants_fixture[0] + assert get_tenant_name(str(tenant.id)) == tenant.name + + @pytest.mark.django_db + def test_get_tenant_name_unknown_or_invalid(self): + assert get_tenant_name("00000000-0000-0000-0000-000000000000") == "" + assert get_tenant_name("not-a-uuid") == "" diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index 3621fd514c..1df7f97bfc 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -124,6 +124,16 @@ To manually send individual Findings to Jira: ![Send to Jira modal](/images/prowler-app/jira/send-to-jira-modal.png) +### Finding Reference in the Jira Issue + +Every Jira issue created from a single Finding carries a stable reference back to that Finding, so issues can be filtered, searched with Jira Query Language (JQL), or matched by automation: + +* **Labels**: `prowler`, `prowler-`, `prowler-`, `prowler-` and `prowler-finding-`. Labels are sanitized deterministically: whitespace becomes `_`, control characters are removed, and values are truncated to Jira's 255-character label limit. +* **Finding URL**: a link that opens the Finding in Prowler App, filtered by its unique identifier (UID) so it keeps working after later scans. +* **Tenant Info**: the name of the Prowler organization that sent the Finding. + +Prowler Cloud always includes the Finding URL. In a self-hosted Prowler App, 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. + ## Integration Status Monitor and manage your Jira integrations through the management interface: