mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
fix: harden Jira site handling (#12012)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens
|
||||
@@ -1,5 +1,8 @@
|
||||
import pytest
|
||||
from api.v1.serializer_utils.integrations import S3ConfigSerializer
|
||||
from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
)
|
||||
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -100,6 +103,59 @@ class TestS3ConfigSerializer:
|
||||
assert "output_directory" in serializer.errors
|
||||
|
||||
|
||||
class TestJiraCredentialSerializer:
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"a",
|
||||
"prowler",
|
||||
"prowler-domain",
|
||||
"A1-b2-C3",
|
||||
"a" * 63,
|
||||
),
|
||||
)
|
||||
def test_valid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
"example.atlassian.net",
|
||||
"-prowler",
|
||||
"prowler-",
|
||||
"a" * 64,
|
||||
" prowler",
|
||||
"prowler ",
|
||||
"prowler\n",
|
||||
),
|
||||
)
|
||||
def test_invalid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "domain" in serializer.errors
|
||||
|
||||
|
||||
class TestImageProviderSecret:
|
||||
"""Test cases for ImageProviderSecret validation."""
|
||||
|
||||
|
||||
@@ -856,7 +856,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -884,7 +884,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="test@example.com",
|
||||
api_token="test_api_token",
|
||||
domain="example.atlassian.net",
|
||||
domain="example",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -917,7 +917,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "invalid@example.com",
|
||||
"api_token": "invalid_token",
|
||||
"domain": "invalid.atlassian.net",
|
||||
"domain": "invalid",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -942,7 +942,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="invalid@example.com",
|
||||
api_token="invalid_token",
|
||||
domain="invalid.atlassian.net",
|
||||
domain="invalid",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -970,7 +970,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {
|
||||
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
|
||||
|
||||
@@ -13423,6 +13423,45 @@ class TestIntegrationViewSet:
|
||||
)
|
||||
assert "credentials" not in response.json()["data"]["attributes"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
),
|
||||
)
|
||||
def test_integrations_create_jira_rejects_invalid_domain(
|
||||
self, authenticated_client, domain
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"domain": domain,
|
||||
"api_token": "fake-api-token",
|
||||
"user_mail": "testing@prowler.com",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert Integration.objects.count() == 0
|
||||
|
||||
def test_integrations_create_valid_relationships(
|
||||
self,
|
||||
authenticated_client,
|
||||
@@ -13963,6 +14002,55 @@ class TestIntegrationViewSet:
|
||||
assert "projects" in configuration
|
||||
assert "issue_types" in configuration
|
||||
|
||||
def test_integrations_update_jira_rejects_invalid_domain(
|
||||
self, authenticated_client
|
||||
):
|
||||
create_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "original-domain",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(create_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
integration_id = create_response.json()["data"]["id"]
|
||||
|
||||
update_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": integration_id,
|
||||
"attributes": {
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "169.254.169.254#",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration_id}),
|
||||
data=json.dumps(update_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
assert integration.credentials["domain"] == "original-domain"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLTokenValidation:
|
||||
|
||||
@@ -5,6 +5,10 @@ from api.v1.serializer_utils.base import BaseValidateSerializer
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
ATLASSIAN_SITE_NAME_REGEX = re.compile(
|
||||
r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
|
||||
)
|
||||
|
||||
|
||||
class S3ConfigSerializer(BaseValidateSerializer):
|
||||
bucket_name = serializers.CharField()
|
||||
@@ -97,7 +101,17 @@ class AWSCredentialSerializer(BaseValidateSerializer):
|
||||
class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
user_mail = serializers.EmailField(required=True)
|
||||
api_token = serializers.CharField(required=True)
|
||||
domain = serializers.CharField(required=True)
|
||||
domain = serializers.RegexField(
|
||||
regex=ATLASSIAN_SITE_NAME_REGEX,
|
||||
required=True,
|
||||
trim_whitespace=False,
|
||||
error_messages={
|
||||
"invalid": (
|
||||
"Domain must be a valid Atlassian site name containing only "
|
||||
"letters, numbers, and hyphens."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
resource_name = "integrations"
|
||||
@@ -170,7 +184,10 @@ class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
|
||||
"description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').",
|
||||
"minLength": 1,
|
||||
"maxLength": 63,
|
||||
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
|
||||
},
|
||||
},
|
||||
"required": ["user_mail", "api_token", "domain"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Jira tenant information requests validate site names and do not follow redirects
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
@@ -37,6 +38,10 @@ from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
)
|
||||
from prowler.providers.common.models import Connection
|
||||
|
||||
ATLASSIAN_SITE_NAME_REGEX = re.compile(
|
||||
r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraConnection(Connection):
|
||||
@@ -665,11 +670,14 @@ class Jira:
|
||||
"""
|
||||
try:
|
||||
if self._using_basic_auth:
|
||||
if not domain or not ATLASSIAN_SITE_NAME_REGEX.fullmatch(domain):
|
||||
raise ValueError("Invalid Jira site name.")
|
||||
headers = self.get_headers(access_token)
|
||||
response = requests.get(
|
||||
f"https://{domain}.atlassian.net/_edge/tenant_info",
|
||||
headers=headers,
|
||||
timeout=self.REQUEST_TIMEOUT,
|
||||
allow_redirects=False,
|
||||
)
|
||||
response = response.json()
|
||||
return response.get("cloudId")
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestJiraIntegration:
|
||||
|
||||
self.user_mail = "test_user_mail"
|
||||
self.api_token = "test_api_token"
|
||||
self.domain = "test_domain"
|
||||
self.domain = "test-domain"
|
||||
|
||||
self.jira_integration_basic_auth = Jira(
|
||||
user_mail=self.user_mail,
|
||||
@@ -386,6 +386,35 @@ class TestJiraIntegration:
|
||||
|
||||
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
),
|
||||
)
|
||||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||||
def test_get_cloud_id_basic_auth_rejects_invalid_domain(self, mock_get, domain):
|
||||
with pytest.raises(JiraGetCloudIDError):
|
||||
self.jira_integration_basic_auth.get_cloud_id(domain=domain)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
|
||||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||||
def test_get_cloud_id_basic_auth_disables_redirects(self, mock_get):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"cloudId": "test_cloud_id"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
self.jira_integration_basic_auth.get_cloud_id(domain=self.domain)
|
||||
|
||||
assert mock_get.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||||
def test_refresh_access_token_sends_timeout(self, mock_post):
|
||||
"""refresh_access_token must pass a request timeout."""
|
||||
|
||||
Reference in New Issue
Block a user