feat(integrations): Support JIRA integration in the API (#8622)

This commit is contained in:
Víctor Fernández Poyatos
2025-09-02 09:53:36 +02:00
committed by GitHub
parent 665c9d878a
commit d4eb4bdca7
10 changed files with 474 additions and 19 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.13.0] (Prowler UNRELEASED)
### Added
- Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622)
---
## [1.12.0] (Prowler 5.11.0)
### Added
+1 -1
View File
@@ -39,7 +39,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.12.0"
version = "1.13.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+149 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.12.0
version: 1.13.0
description: |-
Prowler API specification.
@@ -9132,6 +9132,25 @@ components:
default: false
description: If true, archives findings that are not present in
the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
credentials:
oneOf:
- type: object
@@ -9171,6 +9190,24 @@ components:
- User_Session-1
- Test.Session@2
pattern: ^[a-zA-Z0-9=,.@_-]+$
- type: object
title: JIRA Credentials
properties:
user_mail:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
required:
- user_mail
- domain
- api_token
writeOnly: true
required:
- integration_type
@@ -9292,6 +9329,25 @@ components:
default: false
description: If true, archives findings that are not present
in the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
credentials:
oneOf:
- type: object
@@ -9332,6 +9388,24 @@ components:
- User_Session-1
- Test.Session@2
pattern: ^[a-zA-Z0-9=,.@_-]+$
- type: object
title: JIRA Credentials
properties:
user_mail:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
required:
- user_mail
- domain
- api_token
writeOnly: true
required:
- integration_type
@@ -9468,6 +9542,25 @@ components:
default: false
description: If true, archives findings that are not present in
the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
credentials:
oneOf:
- type: object
@@ -9507,6 +9600,24 @@ components:
- User_Session-1
- Test.Session@2
pattern: ^[a-zA-Z0-9=,.@_-]+$
- type: object
title: JIRA Credentials
properties:
user_mail:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
required:
- user_mail
- domain
- api_token
writeOnly: true
relationships:
type: object
@@ -10873,6 +10984,25 @@ components:
default: false
description: If true, archives findings that are not present
in the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
credentials:
oneOf:
- type: object
@@ -10913,6 +11043,24 @@ components:
- User_Session-1
- Test.Session@2
pattern: ^[a-zA-Z0-9=,.@_-]+$
- type: object
title: JIRA Credentials
properties:
user_mail:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
required:
- user_mail
- domain
- api_token
writeOnly: true
relationships:
type: object
+59
View File
@@ -502,3 +502,62 @@ class TestProwlerIntegrationConnectionTest:
assert integration.configuration["regions"]["eu-west-1"] is True
assert integration.configuration["regions"]["ap-south-1"] is False
integration.save.assert_called_once()
@patch("api.utils.Jira")
def test_jira_connection_success_basic_auth(self, mock_jira_class):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
}
integration.configuration = {
"domain": "example.atlassian.net",
}
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_jira_class.test_connection.return_value = mock_connection
result = prowler_integration_connection_test(integration)
assert result.is_connected is True
assert result.error is None
mock_jira_class.test_connection.assert_called_once_with(
user_mail="test@example.com",
api_token="test_api_token",
domain="example.atlassian.net",
raise_on_exception=False,
)
@patch("api.utils.Jira")
def test_jira_connection_failure_invalid_credentials(self, mock_jira_class):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.credentials = {
"user_mail": "invalid@example.com",
"api_token": "invalid_token",
}
integration.configuration = {
"domain": "invalid.atlassian.net",
}
# Mock failed JIRA connection
mock_connection = MagicMock()
mock_connection.is_connected = False
mock_connection.error = Exception("Authentication failed: Invalid credentials")
mock_jira_class.test_connection.return_value = mock_connection
result = prowler_integration_connection_test(integration)
assert result.is_connected is False
assert "Authentication failed: Invalid credentials" in str(result.error)
mock_jira_class.test_connection.assert_called_once_with(
user_mail="invalid@example.com",
api_token="invalid_token",
domain="invalid.atlassian.net",
raise_on_exception=False,
)
+150
View File
@@ -5660,6 +5660,18 @@ class TestIntegrationViewSet:
},
{},
),
# JIRA
(
Integration.IntegrationChoices.JIRA,
{
"project_key": "JIRA",
"domain": "prowlerdomain",
},
{
"api_token": "this-is-an-api-token-for-jira-that-works-for-sure",
"user_mail": "testing@prowler.com",
},
),
],
)
def test_integrations_create_valid(
@@ -5806,6 +5818,40 @@ class TestIntegrationViewSet:
"invalid",
None,
),
(
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
},
"credentials": {},
},
"required",
"domain",
),
(
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
"domain": "prowlerdomain",
},
},
"required",
"credentials",
),
(
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
"domain": "prowlerdomain",
},
"credentials": {"api_token": "api-token"},
},
"invalid",
"credentials",
),
]
),
)
@@ -5995,6 +6041,110 @@ class TestIntegrationViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_create_duplicate_amazon_s3(
self, authenticated_client, providers_fixture
):
provider = providers_fixture[0]
# Create first S3 integration
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.AMAZON_S3,
"configuration": {
"bucket_name": "test-bucket",
"output_directory": "test-output",
},
"credentials": {
"role_arn": "arn:aws:iam::123456789012:role/test-role",
"external_id": "test-external-id",
},
"enabled": True,
},
"relationships": {
"providers": {
"data": [{"type": "providers", "id": str(provider.id)}]
}
},
}
}
# First creation should succeed
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Attempt to create duplicate should return 409
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_409_CONFLICT
assert (
"This integration already exists" in response.json()["errors"][0]["detail"]
)
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/configuration"
)
def test_integrations_create_duplicate_jira(
self, authenticated_client, providers_fixture
):
provider = providers_fixture[0]
# Create first JIRA integration
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {
"project_key": "TEST",
"domain": "test.atlassian.net",
},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
},
"enabled": True,
},
"relationships": {
"providers": {
"data": [{"type": "providers", "id": str(provider.id)}]
}
},
}
}
# First creation should succeed
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Attempt to create duplicate should return 409
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_409_CONFLICT
assert (
"This integration already exists" in response.json()["errors"][0]["detail"]
)
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/configuration"
)
@pytest.mark.django_db
class TestSAMLTokenValidation:
+8 -2
View File
@@ -9,6 +9,7 @@ from api.db_router import MainRouter
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.v1.serializers import FindingMetadataSerializer
from prowler.lib.outputs.jira.jira import Jira
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
@@ -199,7 +200,8 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
raise_on_exception=False,
)
# TODO: It is possible that we can unify the connection test for all integrations, but need refactoring
# to avoid code duplication. Actually the AWS integrations are similar, so SecurityHub and S3 can be unified making some changes in the SDK.
# to avoid code duplication. Actually the AWS integrations are similar, so SecurityHub and S3 can be unified
# making some changes in the SDK.
elif (
integration.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB
):
@@ -236,7 +238,11 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
return connection
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
pass
return Jira.test_connection(
**integration.credentials,
domain=integration.configuration["domain"],
raise_on_exception=False,
)
elif integration.integration_type == Integration.IntegrationChoices.SLACK:
pass
else:
@@ -67,6 +67,16 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
resource_name = "integrations"
class JiraConfigSerializer(BaseValidateSerializer):
project_key = serializers.CharField(required=True)
domain = serializers.CharField(required=True)
issue_types = serializers.ListField(required=False, child=serializers.CharField())
issue_labels = serializers.ListField(required=False, child=serializers.CharField())
class Meta:
resource_name = "integrations"
class AWSCredentialSerializer(BaseValidateSerializer):
role_arn = serializers.CharField(required=False)
external_id = serializers.CharField(required=False)
@@ -82,6 +92,14 @@ class AWSCredentialSerializer(BaseValidateSerializer):
resource_name = "integrations"
class JiraCredentialSerializer(BaseValidateSerializer):
user_mail = serializers.EmailField(required=True)
api_token = serializers.CharField(required=True)
class Meta:
resource_name = "integrations"
@extend_schema_field(
{
"oneOf": [
@@ -133,6 +151,23 @@ class AWSCredentialSerializer(BaseValidateSerializer):
},
},
},
{
"type": "object",
"title": "JIRA Credentials",
"properties": {
"user_mail": {
"type": "string",
"format": "email",
"description": "The email address of the JIRA user account.",
},
"api_token": {
"type": "string",
"description": "The API token for authentication with JIRA. This can be generated from your "
"Atlassian account settings.",
},
},
"required": ["user_mail", "api_token"],
},
]
}
)
@@ -153,7 +188,10 @@ class IntegrationCredentialField(serializers.JSONField):
},
"output_directory": {
"type": "string",
"description": 'The directory path within the bucket where files will be saved. Optional - defaults to "output" if not provided. Path will be normalized to remove excessive slashes and invalid characters are not allowed (< > : " | ? *). Maximum length is 900 characters.',
"description": "The directory path within the bucket where files will be saved. Optional - "
'defaults to "output" if not provided. Path will be normalized to remove '
'excessive slashes and invalid characters are not allowed (< > : " | ? *). '
"Maximum length is 900 characters.",
"maxLength": 900,
"pattern": '^[^<>:"|?*]+$',
"default": "output",
@@ -177,6 +215,31 @@ class IntegrationCredentialField(serializers.JSONField):
},
},
},
{
"type": "object",
"title": "JIRA",
"properties": {
"project_key": {
"type": "string",
"description": "The JIRA project key where issues will be created (e.g., 'PROJ', 'SEC').",
},
"domain": {
"type": "string",
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
},
"issue_types": {
"type": "array",
"items": {"type": "string"},
"description": "List of JIRA issue types to create for findings.",
},
"issue_labels": {
"type": "array",
"items": {"type": "string"},
"description": "List of labels to apply to created JIRA issues..",
},
},
"required": ["project_key", "domain"],
},
]
}
)
+31 -11
View File
@@ -15,6 +15,7 @@ from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.exceptions import ConflictException
from api.models import (
Finding,
Integration,
@@ -45,6 +46,8 @@ from api.v1.serializer_utils.integrations import (
AWSCredentialSerializer,
IntegrationConfigField,
IntegrationCredentialField,
JiraConfigSerializer,
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
)
@@ -1952,18 +1955,34 @@ class ScheduleDailyCreateSerializer(serializers.Serializer):
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
def validate(self, attrs):
integration_type = attrs.get("integration_type")
if (
attrs.get("integration_type") == Integration.IntegrationChoices.AMAZON_S3
integration_type == Integration.IntegrationChoices.AMAZON_S3
and Integration.objects.filter(
configuration=attrs.get("configuration")
).exists()
):
raise serializers.ValidationError(
{"configuration": "This integration already exists."}
raise ConflictException(
detail="This integration already exists.",
pointer="/data/attributes/configuration",
)
if (
integration_type == Integration.IntegrationChoices.JIRA
and Integration.objects.filter(
configuration__contains={
"domain": attrs.get("configuration").get("domain"),
"project_key": attrs.get("configuration").get("project_key"),
}
).exists()
):
raise ConflictException(
detail="This integration already exists.",
pointer="/data/attributes/configuration",
)
# Check if any provider already has a SecurityHub integration
integration_type = attrs.get("integration_type")
if hasattr(self, "instance") and self.instance and not integration_type:
integration_type = self.instance.integration_type
@@ -1984,10 +2003,10 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
query = query.exclude(integration=self.instance)
if query.exists():
raise serializers.ValidationError(
{
"providers": f"Provider {provider.id} already has a Security Hub integration. Only one Security Hub integration is allowed per provider."
}
raise ConflictException(
detail=f"Provider {provider.id} already has a Security Hub integration. Only one "
"Security Hub integration is allowed per provider.",
pointer="/data/relationships/providers",
)
return super().validate(attrs)
@@ -2018,6 +2037,9 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
config_serializer = SecurityHubConfigSerializer
credentials_serializers = [AWSCredentialSerializer]
elif integration_type == Integration.IntegrationChoices.JIRA:
config_serializer = JiraConfigSerializer
credentials_serializers = [JiraCredentialSerializer]
else:
raise serializers.ValidationError(
{
@@ -2122,9 +2144,7 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
and integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB
):
raise serializers.ValidationError(
{
"providers": "At least one provider is required for the Security Hub integration."
}
{"providers": "At least one provider is required for this integration."}
)
self.validate_integration_data(
+1 -1
View File
@@ -293,7 +293,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.12.0"
spectacular_settings.VERSION = "1.13.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
+4 -2
View File
@@ -330,7 +330,8 @@ def upload_security_hub_integration(
if not connected:
logger.error(
f"Security Hub connection failed for integration {integration.id}: {security_hub.error}"
f"Security Hub connection failed for integration {integration.id}: "
f"{security_hub.error}"
)
integration.connected = False
integration.save()
@@ -338,7 +339,8 @@ def upload_security_hub_integration(
security_hub_client = security_hub
logger.info(
f"Sending {'fail' if send_only_fails else 'all'} findings to Security Hub via integration {integration.id}"
f"Sending {'fail' if send_only_fails else 'all'} findings to Security Hub via "
f"integration {integration.id}"
)
else:
# Update findings in existing client for this batch