mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(api): add GitHub integration for sending findings as issues
Add complete GitHub integration to Prowler that allows sending security
findings as GitHub Issues, working similarly to the existing Jira integration.
Features:
- GitHub API client with Personal Access Token (PAT) authentication
- Support for creating issues from Prowler findings
- Automatic repository discovery and validation
- Label support for issue categorization
- Rich markdown-formatted issues with detailed finding information
- Full API integration with CRUD operations
- Async task processing for bulk operations
- Connection testing and validation
Implementation:
- Add GitHub API client in prowler/lib/outputs/github/
- Add GitHub to Integration model choices
- Create serializers and validators for GitHub credentials and configuration
- Implement IntegrationGitHubViewSet for API endpoints
- Add async tasks and job processing for sending findings
- Add URL routing for /integrations/{id}/github/dispatches endpoint
API Changes:
- New integration type: "github"
- Credentials: token (required), owner (optional)
- Configuration: repositories dict, owner string
- Dispatch endpoint accepts repository and optional labels
Issue Format:
- Title: [Prowler] SEVERITY - CHECK_ID - RESOURCE_UID
- Body includes: finding details, risk description, recommendations,
remediation code (CLI/Terraform/IaC), resource tags, compliance info
Security:
- GitHub PAT encrypted with Fernet before storage
- Repository access validated before dispatch
- All API calls use HTTPS
- Comprehensive error handling and logging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
df8d82345d
commit
a8c244849f
13 files changed
+1070
-5
No files matched your search
@@ -957,6 +957,26 @@ class ProcessorFilter(FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class IntegrationGitHubFindingsFilter(FilterSet):
|
||||
# To be expanded as needed
|
||||
finding_id = UUIDFilter(field_name="id", lookup_expr="exact")
|
||||
finding_id__in = UUIDInFilter(field_name="id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = {}
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
# Validate that there is at least one filter provided
|
||||
if not self.data:
|
||||
raise ValidationError(
|
||||
{
|
||||
"findings": "No finding filters provided. At least one filter is required."
|
||||
}
|
||||
)
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
|
||||
class IntegrationJiraFindingsFilter(FilterSet):
|
||||
# To be expanded as needed
|
||||
finding_id = UUIDFilter(field_name="id", lookup_expr="exact")
|
||||
|
||||
@@ -1586,6 +1586,7 @@ class Integration(RowLevelSecurityProtectedModel):
|
||||
class IntegrationChoices(models.TextChoices):
|
||||
AMAZON_S3 = "amazon_s3", _("Amazon S3")
|
||||
AWS_SECURITY_HUB = "aws_security_hub", _("AWS Security Hub")
|
||||
GITHUB = "github", _("GitHub")
|
||||
JIRA = "jira", _("JIRA")
|
||||
SLACK = "slack", _("Slack")
|
||||
|
||||
|
||||
@@ -287,6 +287,20 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
|
||||
integration.save()
|
||||
|
||||
return connection
|
||||
elif integration.integration_type == Integration.IntegrationChoices.GITHUB:
|
||||
from prowler.lib.outputs.github.github import GitHub
|
||||
|
||||
github_connection = GitHub.test_connection(
|
||||
**integration.credentials,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
repositories = (
|
||||
github_connection.repositories if github_connection.is_connected else {}
|
||||
)
|
||||
with rls_transaction(str(integration.tenant_id)):
|
||||
integration.configuration["repositories"] = repositories
|
||||
integration.save()
|
||||
return github_connection
|
||||
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
jira_connection = Jira.test_connection(
|
||||
**integration.credentials,
|
||||
@@ -406,9 +420,22 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
|
||||
return serializer.data
|
||||
|
||||
|
||||
def initialize_prowler_integration(integration: Integration) -> Jira:
|
||||
def initialize_prowler_integration(integration: Integration):
|
||||
# TODO Refactor other integrations to use this function
|
||||
if integration.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
if integration.integration_type == Integration.IntegrationChoices.GITHUB:
|
||||
from prowler.lib.outputs.github.exceptions import GitHubAuthenticationError
|
||||
from prowler.lib.outputs.github.github import GitHub
|
||||
|
||||
try:
|
||||
return GitHub(**integration.credentials)
|
||||
except GitHubAuthenticationError as github_auth_error:
|
||||
with rls_transaction(str(integration.tenant_id)):
|
||||
integration.configuration["repositories"] = {}
|
||||
integration.connected = False
|
||||
integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
|
||||
integration.save()
|
||||
raise github_auth_error
|
||||
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
try:
|
||||
return Jira(**integration.credentials)
|
||||
except JiraBasicAuthError as jira_auth_error:
|
||||
|
||||
@@ -67,6 +67,14 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
|
||||
resource_name = "integrations"
|
||||
|
||||
|
||||
class GitHubConfigSerializer(BaseValidateSerializer):
|
||||
owner = serializers.CharField(read_only=True)
|
||||
repositories = serializers.DictField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
resource_name = "integrations"
|
||||
|
||||
|
||||
class JiraConfigSerializer(BaseValidateSerializer):
|
||||
domain = serializers.CharField(read_only=True)
|
||||
issue_types = serializers.ListField(
|
||||
@@ -93,6 +101,14 @@ class AWSCredentialSerializer(BaseValidateSerializer):
|
||||
resource_name = "integrations"
|
||||
|
||||
|
||||
class GitHubCredentialSerializer(BaseValidateSerializer):
|
||||
token = serializers.CharField(required=True)
|
||||
owner = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
resource_name = "integrations"
|
||||
|
||||
|
||||
class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
user_mail = serializers.EmailField(required=True)
|
||||
api_token = serializers.CharField(required=True)
|
||||
@@ -153,6 +169,23 @@ class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "GitHub Credentials",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "GitHub Personal Access Token (PAT) with repo scope. Can be generated from "
|
||||
"GitHub Settings > Developer settings > Personal access tokens.",
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner (username or organization name). Optional - if not provided, "
|
||||
"all accessible repositories will be available.",
|
||||
},
|
||||
},
|
||||
"required": ["token"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "JIRA Credentials",
|
||||
@@ -221,6 +254,14 @@ class IntegrationCredentialField(serializers.JSONField):
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "GitHub",
|
||||
"description": "GitHub integration does not accept any configuration in the payload. Leave it as an "
|
||||
"empty JSON object (`{}`).",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "JIRA",
|
||||
|
||||
@@ -54,6 +54,8 @@ from api.models import (
|
||||
from api.rls import Tenant
|
||||
from api.v1.serializer_utils.integrations import (
|
||||
AWSCredentialSerializer,
|
||||
GitHubConfigSerializer,
|
||||
GitHubCredentialSerializer,
|
||||
IntegrationConfigField,
|
||||
IntegrationCredentialField,
|
||||
JiraConfigSerializer,
|
||||
@@ -2432,6 +2434,28 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
config_serializer = SecurityHubConfigSerializer
|
||||
credentials_serializers = [AWSCredentialSerializer]
|
||||
elif integration_type == Integration.IntegrationChoices.GITHUB:
|
||||
if providers:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"providers": "Relationship field is not accepted. This integration applies to all providers."
|
||||
}
|
||||
)
|
||||
if configuration:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"configuration": "This integration does not support custom configuration."
|
||||
}
|
||||
)
|
||||
config_serializer = GitHubConfigSerializer
|
||||
# Create non-editable configuration for GitHub integration
|
||||
configuration.update(
|
||||
{
|
||||
"repositories": {},
|
||||
"owner": credentials.get("owner", ""),
|
||||
}
|
||||
)
|
||||
credentials_serializers = [GitHubCredentialSerializer]
|
||||
elif integration_type == Integration.IntegrationChoices.JIRA:
|
||||
if providers:
|
||||
raise serializers.ValidationError(
|
||||
@@ -2519,7 +2543,11 @@ class IntegrationSerializer(RLSSerializer):
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
if instance.integration_type == Integration.IntegrationChoices.GITHUB:
|
||||
representation["configuration"].update(
|
||||
{"owner": instance.credentials.get("owner", "")}
|
||||
)
|
||||
elif instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
{"domain": instance.credentials.get("domain")}
|
||||
)
|
||||
@@ -2666,6 +2694,51 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
return representation
|
||||
|
||||
|
||||
class IntegrationGitHubDispatchSerializer(BaseSerializerV1):
|
||||
"""
|
||||
Serializer for dispatching findings to GitHub integration.
|
||||
"""
|
||||
|
||||
repository = serializers.CharField(required=True)
|
||||
labels = serializers.ListField(
|
||||
child=serializers.CharField(), required=False, default=list
|
||||
)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "integrations-github-dispatches"
|
||||
|
||||
def validate(self, attrs):
|
||||
validated_attrs = super().validate(attrs)
|
||||
integration_instance = Integration.objects.get(
|
||||
id=self.context.get("integration_id")
|
||||
)
|
||||
if (
|
||||
integration_instance.integration_type
|
||||
!= Integration.IntegrationChoices.GITHUB
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"integration_type": "The given integration is not a GitHub integration"
|
||||
}
|
||||
)
|
||||
|
||||
if not integration_instance.enabled:
|
||||
raise ValidationError(
|
||||
{"integration": "The given integration is not enabled"}
|
||||
)
|
||||
|
||||
repository = attrs.get("repository")
|
||||
if repository not in integration_instance.configuration.get("repositories", {}):
|
||||
raise ValidationError(
|
||||
{
|
||||
"repository": "The given repository is not available for this GitHub integration. Refresh the "
|
||||
"connection if this is an error."
|
||||
}
|
||||
)
|
||||
|
||||
return validated_attrs
|
||||
|
||||
|
||||
class IntegrationJiraDispatchSerializer(BaseSerializerV1):
|
||||
"""
|
||||
Serializer for dispatching findings to JIRA integration.
|
||||
|
||||
@@ -12,6 +12,7 @@ from api.v1.views import (
|
||||
FindingViewSet,
|
||||
GithubSocialLoginView,
|
||||
GoogleSocialLoginView,
|
||||
IntegrationGitHubViewSet,
|
||||
IntegrationJiraViewSet,
|
||||
IntegrationViewSet,
|
||||
InvitationAcceptViewSet,
|
||||
@@ -94,6 +95,9 @@ users_router.register(r"memberships", MembershipViewSet, basename="user-membersh
|
||||
integrations_router = routers.NestedSimpleRouter(
|
||||
router, r"integrations", lookup="integration"
|
||||
)
|
||||
integrations_router.register(
|
||||
r"github", IntegrationGitHubViewSet, basename="integration-github"
|
||||
)
|
||||
integrations_router.register(
|
||||
r"jira", IntegrationJiraViewSet, basename="integration-jira"
|
||||
)
|
||||
|
||||
@@ -83,6 +83,7 @@ from tasks.tasks import (
|
||||
check_provider_connection_task,
|
||||
delete_provider_task,
|
||||
delete_tenant_task,
|
||||
github_integration_task,
|
||||
jira_integration_task,
|
||||
mute_historical_findings_task,
|
||||
perform_scan_task,
|
||||
@@ -105,6 +106,7 @@ from api.filters import (
|
||||
DailySeveritySummaryFilter,
|
||||
FindingFilter,
|
||||
IntegrationFilter,
|
||||
IntegrationGitHubFindingsFilter,
|
||||
IntegrationJiraFindingsFilter,
|
||||
InvitationFilter,
|
||||
LatestFindingFilter,
|
||||
@@ -190,6 +192,7 @@ from api.v1.serializers import (
|
||||
FindingSerializer,
|
||||
FindingsSeverityOverTimeSerializer,
|
||||
IntegrationCreateSerializer,
|
||||
IntegrationGitHubDispatchSerializer,
|
||||
IntegrationJiraDispatchSerializer,
|
||||
IntegrationSerializer,
|
||||
IntegrationUpdateSerializer,
|
||||
@@ -5131,6 +5134,86 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
dispatches=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Send findings to a GitHub integration",
|
||||
description="Send a set of filtered findings to the given GitHub integration as issues. At least one finding "
|
||||
"filter must be provided.",
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
filters=True,
|
||||
)
|
||||
)
|
||||
class IntegrationGitHubViewSet(BaseRLSViewSet):
|
||||
queryset = Finding.all_objects.all()
|
||||
serializer_class = IntegrationGitHubDispatchSerializer
|
||||
http_method_names = ["post"]
|
||||
filter_backends = [CustomDjangoFilterBackend]
|
||||
filterset_class = IntegrationGitHubFindingsFilter
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def create(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="POST")
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter findings based on provider groups associated with the role
|
||||
queryset = Finding.all_objects.filter(
|
||||
scan__provider__in=get_providers(user_roles)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=["post"], url_name="dispatches")
|
||||
def dispatches(self, request, integration_pk=None):
|
||||
get_object_or_404(Integration, pk=integration_pk)
|
||||
serializer = self.get_serializer(
|
||||
data=request.data, context={"integration_id": integration_pk}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if self.filter_queryset(self.get_queryset()).count() == 0:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
finding_ids = [
|
||||
str(finding_id)
|
||||
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
]
|
||||
repository = serializer.validated_data["repository"]
|
||||
labels = serializer.validated_data.get("labels", [])
|
||||
|
||||
with transaction.atomic():
|
||||
task = github_integration_task.delay(
|
||||
tenant_id=self.request.tenant_id,
|
||||
integration_id=integration_pk,
|
||||
repository=repository,
|
||||
labels=labels,
|
||||
finding_ids=finding_ids,
|
||||
)
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
serializer = TaskSerializer(prowler_task)
|
||||
return Response(
|
||||
data=serializer.data,
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
headers={
|
||||
"Content-Location": reverse(
|
||||
"task-detail", kwargs={"pk": prowler_task.id}
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
dispatches=extend_schema(
|
||||
tags=["Integration"],
|
||||
|
||||
@@ -17,11 +17,11 @@ from prowler.lib.outputs.html.html import HTML
|
||||
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
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
|
||||
from prowler.providers.common.models import Connection
|
||||
from prowler.providers.aws.lib.security_hub.exceptions.exceptions import (
|
||||
SecurityHubNoEnabledRegionsError,
|
||||
)
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
|
||||
from prowler.providers.common.models import Connection
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -436,6 +436,81 @@ def upload_security_hub_integration(
|
||||
return False
|
||||
|
||||
|
||||
def send_findings_to_github(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
repository: str,
|
||||
labels: list[str],
|
||||
finding_ids: list[str],
|
||||
):
|
||||
with rls_transaction(tenant_id):
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
github_integration = initialize_prowler_integration(integration)
|
||||
|
||||
num_issues_created = 0
|
||||
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)
|
||||
)
|
||||
|
||||
# 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", {})
|
||||
|
||||
# Send the individual finding to GitHub
|
||||
result = github_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=finding_instance.scan.provider.provider,
|
||||
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 {},
|
||||
repository=repository,
|
||||
issue_labels=labels,
|
||||
)
|
||||
if result:
|
||||
num_issues_created += 1
|
||||
else:
|
||||
logger.error(f"Failed to send finding {finding_id} to GitHub")
|
||||
|
||||
return {
|
||||
"created_count": num_issues_created,
|
||||
"failed_count": len(finding_ids) - num_issues_created,
|
||||
}
|
||||
|
||||
|
||||
def send_findings_to_jira(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
|
||||
@@ -28,6 +28,7 @@ from tasks.jobs.export import (
|
||||
_upload_to_s3,
|
||||
)
|
||||
from tasks.jobs.integrations import (
|
||||
send_findings_to_github,
|
||||
send_findings_to_jira,
|
||||
upload_s3_integration,
|
||||
upload_security_hub_integration,
|
||||
@@ -791,6 +792,23 @@ def security_hub_integration_task(
|
||||
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask,
|
||||
name="integration-github",
|
||||
queue="integrations",
|
||||
)
|
||||
def github_integration_task(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
repository: str,
|
||||
labels: list[str],
|
||||
finding_ids: list[str],
|
||||
):
|
||||
return send_findings_to_github(
|
||||
tenant_id, integration_id, repository, labels, finding_ids
|
||||
)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask,
|
||||
name="integration-jira",
|
||||
|
||||
Reference in new issue
Block a user