mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 10:14:20 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc2710418 | ||
|
|
1ced1af07e | ||
|
|
d8a9878a2c | ||
|
|
8097b8e012 | ||
|
|
53be73c2ac |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +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
|
||||
+4
-1
@@ -45,7 +45,10 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
# TEMPORARY (draft only): pinned to the head of prowler-cloud/prowler#12539 so this
|
||||
# branch runs against the new Jira SDK helpers. Before this PR leaves draft: restore
|
||||
# "@master", run `uv lock --upgrade-package prowler`, and delete this comment.
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@23e048fcd20738b68b54be6adc8b43f8a26d0d43",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
|
||||
@@ -17,6 +17,7 @@ from api.models import (
|
||||
FindingGroupDailySummary,
|
||||
Integration,
|
||||
Invitation,
|
||||
JiraIssue,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
Membership,
|
||||
@@ -1900,3 +1901,30 @@ class ComplianceWatchlistFilter(BaseProviderFilter):
|
||||
|
||||
class Meta(BaseProviderFilter.Meta):
|
||||
model = ProviderComplianceScore
|
||||
|
||||
|
||||
class JiraIssueFilter(BaseProviderFilter):
|
||||
finding_uid = CharFilter(field_name="finding_uid", lookup_expr="exact")
|
||||
finding_uid__in = CharInFilter(field_name="finding_uid", lookup_expr="in")
|
||||
finding_id = UUIDFilter(field_name="finding_id", lookup_expr="exact")
|
||||
finding_id__in = UUIDInFilter(field_name="finding_id", lookup_expr="in")
|
||||
integration = UUIDFilter(field_name="integration__id", lookup_expr="exact")
|
||||
integration__in = UUIDInFilter(field_name="integration__id", lookup_expr="in")
|
||||
issue_key = CharFilter(field_name="issue_key", lookup_expr="exact")
|
||||
issue_key__in = CharInFilter(field_name="issue_key", lookup_expr="in")
|
||||
issue_status_category = ChoiceFilter(
|
||||
choices=JiraIssue.StatusCategoryChoices.choices
|
||||
)
|
||||
issue_status_category__in = ChoiceInFilter(
|
||||
choices=JiraIssue.StatusCategoryChoices.choices,
|
||||
field_name="issue_status_category",
|
||||
lookup_expr="in",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JiraIssue
|
||||
fields = {
|
||||
"inserted_at": ["date", "gte", "lte"],
|
||||
"updated_at": ["date", "gte", "lte"],
|
||||
"project_key": ["exact", "in"],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import uuid
|
||||
|
||||
import api.rls
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0097_attack_paths_scan_db_defaults"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="JiraIssue",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("finding_uid", models.CharField(max_length=300)),
|
||||
("finding_id", models.UUIDField()),
|
||||
(
|
||||
"issue_key",
|
||||
models.CharField(blank=True, default="", max_length=64),
|
||||
),
|
||||
(
|
||||
"issue_id",
|
||||
models.CharField(blank=True, default="", max_length=64),
|
||||
),
|
||||
(
|
||||
"issue_url",
|
||||
models.URLField(blank=True, default="", max_length=2048),
|
||||
),
|
||||
("project_key", models.CharField(max_length=64)),
|
||||
(
|
||||
"issue_status",
|
||||
models.CharField(blank=True, default="", max_length=64),
|
||||
),
|
||||
(
|
||||
"issue_status_category",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("new", "New"),
|
||||
("indeterminate", "In progress"),
|
||||
("done", "Done"),
|
||||
],
|
||||
default="",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("status_synced_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"integration",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="jira_issues",
|
||||
to="api.integration",
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="jira_issues",
|
||||
to="api.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "jira_issues",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="jiraissue",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id", "integration_id", "provider_id", "finding_uid"),
|
||||
name="unique_jira_issue_per_finding",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="jiraissue",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_jiraissue",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0098_jira_issues"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="jiraissue",
|
||||
index=models.Index(
|
||||
fields=["tenant_id", "provider_id", "finding_uid"],
|
||||
name="ji_tenant_prov_uid_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -3113,3 +3113,77 @@ class TenantComplianceSummary(RowLevelSecurityProtectedModel):
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class JiraIssue(RowLevelSecurityProtectedModel):
|
||||
"""Jira issue created from a finding through a Jira integration.
|
||||
|
||||
One row per (integration, provider, finding uid). Keyed on the finding ``uid``
|
||||
rather than the per-scan finding id so the link survives rescans, which is
|
||||
what lets a repeated send be recognised as already ticketed. Only the latest
|
||||
ticket is kept: when a linked issue is closed or deleted in Jira and the
|
||||
finding is sent again, the row is updated to point at the new issue.
|
||||
"""
|
||||
|
||||
class StatusCategoryChoices(models.TextChoices):
|
||||
NEW = "new", _("New")
|
||||
INDETERMINATE = "indeterminate", _("In progress")
|
||||
DONE = "done", _("Done")
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
integration = models.ForeignKey(
|
||||
Integration, on_delete=models.CASCADE, related_name="jira_issues"
|
||||
)
|
||||
provider = models.ForeignKey(
|
||||
Provider, on_delete=models.CASCADE, related_name="jira_issues"
|
||||
)
|
||||
finding_uid = models.CharField(max_length=300)
|
||||
# Last finding record that was sent; informational, findings are partitioned
|
||||
# and rotate per scan so this is not a foreign key
|
||||
finding_id = models.UUIDField()
|
||||
# Empty while the issue is being created (reservation), filled after Jira
|
||||
# confirms the creation
|
||||
issue_key = models.CharField(max_length=64, blank=True, default="")
|
||||
issue_id = models.CharField(max_length=64, blank=True, default="")
|
||||
issue_url = models.URLField(max_length=2048, blank=True, default="")
|
||||
project_key = models.CharField(max_length=64)
|
||||
issue_status = models.CharField(max_length=64, blank=True, default="")
|
||||
issue_status_category = models.CharField(
|
||||
max_length=16, choices=StatusCategoryChoices.choices, blank=True, default=""
|
||||
)
|
||||
status_synced_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "jira_issues"
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "integration_id", "provider_id", "finding_uid"),
|
||||
name="unique_jira_issue_per_finding",
|
||||
),
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id", "finding_uid"],
|
||||
name="ji_tenant_prov_uid_idx",
|
||||
),
|
||||
]
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "jira-issues"
|
||||
|
||||
@property
|
||||
def is_linked(self) -> bool:
|
||||
"""Whether the row points at a confirmed Jira issue (not a reservation)."""
|
||||
return bool(self.issue_key)
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return self.issue_status_category == self.StatusCategoryChoices.DONE
|
||||
|
||||
@@ -7107,6 +7107,405 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OpenApiResponseResponse'
|
||||
description: ''
|
||||
/api/v1/jira-issues:
|
||||
get:
|
||||
operationId: api_v1_jira_issues_list
|
||||
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.
|
||||
summary: List Jira issues linked to findings
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[jira-issues]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- inserted_at
|
||||
- updated_at
|
||||
- finding_uid
|
||||
- finding_id
|
||||
- issue_key
|
||||
- issue_id
|
||||
- issue_url
|
||||
- project_key
|
||||
- issue_status
|
||||
- issue_status_category
|
||||
- status_synced_at
|
||||
- integration
|
||||
- provider
|
||||
- url
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: query
|
||||
name: filter[finding_id]
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: filter[finding_id__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[finding_uid]
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: filter[finding_uid__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[inserted_at__date]
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[inserted_at__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[inserted_at__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[integration]
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: filter[integration__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[issue_key]
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: filter[issue_key__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[issue_status_category]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 6e5c623f6bbdd92d
|
||||
enum:
|
||||
- done
|
||||
- indeterminate
|
||||
- new
|
||||
description: |-
|
||||
* `new` - New
|
||||
* `indeterminate` - In progress
|
||||
* `done` - Done
|
||||
- in: query
|
||||
name: filter[issue_status_category__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 6e5c623f6bbdd92d
|
||||
enum:
|
||||
- done
|
||||
- indeterminate
|
||||
- new
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
* `new` - New
|
||||
* `indeterminate` - In progress
|
||||
* `done` - Done
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[project_key]
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: filter[project_key__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[provider_groups]
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: filter[provider_groups__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[provider_id]
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: filter[provider_id__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 203afc16daac9b64
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- cloudflare
|
||||
- gcp
|
||||
- github
|
||||
- googleworkspace
|
||||
- iac
|
||||
- image
|
||||
- kubernetes
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- okta
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
* `gcp` - GCP
|
||||
* `kubernetes` - Kubernetes
|
||||
* `m365` - M365
|
||||
* `github` - GitHub
|
||||
* `mongodbatlas` - MongoDB Atlas
|
||||
* `iac` - IaC
|
||||
* `oraclecloud` - Oracle Cloud Infrastructure
|
||||
* `alibabacloud` - Alibaba Cloud
|
||||
* `cloudflare` - Cloudflare
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
* `okta` - Okta
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 203afc16daac9b64
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
- azure
|
||||
- cloudflare
|
||||
- gcp
|
||||
- github
|
||||
- googleworkspace
|
||||
- iac
|
||||
- image
|
||||
- kubernetes
|
||||
- m365
|
||||
- mongodbatlas
|
||||
- okta
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
* `gcp` - GCP
|
||||
* `kubernetes` - Kubernetes
|
||||
* `m365` - M365
|
||||
* `github` - GitHub
|
||||
* `mongodbatlas` - MongoDB Atlas
|
||||
* `iac` - IaC
|
||||
* `oraclecloud` - Oracle Cloud Infrastructure
|
||||
* `alibabacloud` - Alibaba Cloud
|
||||
* `cloudflare` - Cloudflare
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
* `okta` - Okta
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: filter[updated_at__date]
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[updated_at__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[updated_at__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: include
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- provider
|
||||
description: include query parameter to allow the client to customize which
|
||||
related resources should be returned.
|
||||
explode: false
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page[size]
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- inserted_at
|
||||
- -inserted_at
|
||||
- updated_at
|
||||
- -updated_at
|
||||
- issue_key
|
||||
- -issue_key
|
||||
- project_key
|
||||
- -project_key
|
||||
- issue_status
|
||||
- -issue_status
|
||||
- status_synced_at
|
||||
- -status_synced_at
|
||||
explode: false
|
||||
tags:
|
||||
- Integration
|
||||
security:
|
||||
- JWT or API Key: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedJiraIssueList'
|
||||
description: ''
|
||||
/api/v1/jira-issues/{id}:
|
||||
get:
|
||||
operationId: api_v1_jira_issues_retrieve
|
||||
description: Fetch the Jira issue linked to a finding by the link ID.
|
||||
summary: Retrieve a Jira issue link
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[jira-issues]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- inserted_at
|
||||
- updated_at
|
||||
- finding_uid
|
||||
- finding_id
|
||||
- issue_key
|
||||
- issue_id
|
||||
- issue_url
|
||||
- project_key
|
||||
- issue_status
|
||||
- issue_status_category
|
||||
- status_synced_at
|
||||
- integration
|
||||
- provider
|
||||
- url
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this jira issue.
|
||||
required: true
|
||||
- in: query
|
||||
name: include
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- provider
|
||||
description: include query parameter to allow the client to customize which
|
||||
related resources should be returned.
|
||||
explode: false
|
||||
tags:
|
||||
- Integration
|
||||
security:
|
||||
- JWT or API Key: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/JiraIssueResponse'
|
||||
description: ''
|
||||
/api/v1/lighthouse-configurations:
|
||||
get:
|
||||
operationId: api_v1_lighthouse_configurations_list
|
||||
@@ -18618,6 +19017,134 @@ components:
|
||||
$ref: '#/components/schemas/InvitationUpdate'
|
||||
required:
|
||||
- data
|
||||
JiraIssue:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- jira-issues
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
inserted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
finding_uid:
|
||||
type: string
|
||||
readOnly: true
|
||||
finding_id:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
issue_key:
|
||||
type: string
|
||||
readOnly: true
|
||||
issue_id:
|
||||
type: string
|
||||
readOnly: true
|
||||
issue_url:
|
||||
type: string
|
||||
format: uri
|
||||
readOnly: true
|
||||
project_key:
|
||||
type: string
|
||||
readOnly: true
|
||||
issue_status:
|
||||
type: string
|
||||
readOnly: true
|
||||
issue_status_category:
|
||||
enum:
|
||||
- new
|
||||
- indeterminate
|
||||
- done
|
||||
type: string
|
||||
description: |-
|
||||
* `new` - New
|
||||
* `indeterminate` - In progress
|
||||
* `done` - Done
|
||||
x-spec-enum-id: 6e5c623f6bbdd92d
|
||||
readOnly: true
|
||||
status_synced_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
nullable: true
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
integration:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- integrations
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
provider:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- providers
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
JiraIssueResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/JiraIssue'
|
||||
required:
|
||||
- data
|
||||
LighthouseConfig:
|
||||
type: object
|
||||
required:
|
||||
@@ -20227,6 +20754,15 @@ components:
|
||||
$ref: '#/components/schemas/Invitation'
|
||||
required:
|
||||
- data
|
||||
PaginatedJiraIssueList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/JiraIssue'
|
||||
required:
|
||||
- data
|
||||
PaginatedLighthouseConfigList:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from api.db_router import MainRouter
|
||||
from api.models import (
|
||||
JiraIssue,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
@@ -524,3 +525,55 @@ class TestTenantComplianceSummaryModel:
|
||||
|
||||
assert summary1.id != summary2.id
|
||||
assert summary1.requirements_passed != summary2.requirements_passed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJiraIssueModel:
|
||||
def test_create_jira_issue(
|
||||
self, jira_integration_fixture, aws_provider, findings_fixture
|
||||
):
|
||||
finding = findings_fixture[0]
|
||||
issue = JiraIssue.objects.create(
|
||||
tenant_id=jira_integration_fixture.tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider=aws_provider,
|
||||
finding_uid=finding.uid,
|
||||
finding_id=finding.id,
|
||||
issue_key="TEST-1",
|
||||
project_key="TEST",
|
||||
)
|
||||
assert issue.is_linked
|
||||
assert not issue.is_done
|
||||
assert issue.issue_status_category == ""
|
||||
|
||||
def test_reservation_is_not_linked(
|
||||
self, jira_integration_fixture, aws_provider, findings_fixture
|
||||
):
|
||||
finding = findings_fixture[0]
|
||||
issue = JiraIssue.objects.create(
|
||||
tenant_id=jira_integration_fixture.tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider=aws_provider,
|
||||
finding_uid=finding.uid,
|
||||
finding_id=finding.id,
|
||||
project_key="TEST",
|
||||
)
|
||||
assert not issue.is_linked
|
||||
|
||||
def test_unique_per_integration_provider_and_finding_uid(
|
||||
self, jira_integration_fixture, aws_provider_pair, findings_fixture
|
||||
):
|
||||
provider, provider2 = aws_provider_pair
|
||||
finding = findings_fixture[0]
|
||||
common = {
|
||||
"tenant_id": jira_integration_fixture.tenant_id,
|
||||
"integration": jira_integration_fixture,
|
||||
"finding_uid": finding.uid,
|
||||
"finding_id": finding.id,
|
||||
"project_key": "TEST",
|
||||
}
|
||||
JiraIssue.objects.create(provider=provider, issue_key="TEST-1", **common)
|
||||
# Same finding uid on another provider is a different finding
|
||||
JiraIssue.objects.create(provider=provider2, issue_key="TEST-2", **common)
|
||||
with pytest.raises(IntegrityError):
|
||||
JiraIssue.objects.create(provider=provider, issue_key="TEST-3", **common)
|
||||
|
||||
@@ -1591,6 +1591,19 @@ class TestLimitedVisibility:
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_jira_issues_limited_to_visible_providers(
|
||||
self, authenticated_client_rbac_limited, jira_issues_fixture
|
||||
):
|
||||
linked, other_provider_issue, _ = 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)]
|
||||
|
||||
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_types_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration_fixture
|
||||
):
|
||||
|
||||
@@ -34,6 +34,7 @@ from api.models import (
|
||||
Integration,
|
||||
Invitation,
|
||||
InvitationRoleRelationship,
|
||||
JiraIssue,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
LighthouseTenantConfiguration,
|
||||
@@ -13557,6 +13558,144 @@ class TestScheduleViewSet:
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJiraIssueViewSet:
|
||||
def test_list_hides_reservations(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
|
||||
|
||||
def test_retrieve(self, authenticated_client, jira_issues_fixture):
|
||||
linked, *_ = jira_issues_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-detail", kwargs={"pk": linked.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert data["type"] == "jira-issues"
|
||||
attributes = data["attributes"]
|
||||
assert attributes["finding_uid"] == linked.finding_uid
|
||||
assert attributes["finding_id"] == str(linked.finding_id)
|
||||
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_status"] == "To Do"
|
||||
assert attributes["issue_status_category"] == "new"
|
||||
assert attributes["status_synced_at"] is not None
|
||||
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(
|
||||
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
|
||||
|
||||
def test_retrieve_other_tenant_returns_404(
|
||||
self, authenticated_client, jira_issues_fixture, tenants_fixture
|
||||
):
|
||||
linked, *_ = jira_issues_fixture
|
||||
with rls_transaction(str(tenants_fixture[2].id)):
|
||||
JiraIssue.objects.filter(id=linked.id).update(
|
||||
tenant_id=tenants_fixture[2].id
|
||||
)
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-detail", kwargs={"pk": linked.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_keys",
|
||||
[
|
||||
("finding_uid", "test_finding_uid_1", {"TEST-1"}),
|
||||
(
|
||||
"finding_uid__in",
|
||||
"test_finding_uid_1,test_finding_uid_other_provider",
|
||||
{"TEST-1", "TEST-2"},
|
||||
),
|
||||
("finding_uid__in", "does-not-exist", set()),
|
||||
("issue_key", "TEST-2", {"TEST-2"}),
|
||||
("issue_status_category", "done", {"TEST-2"}),
|
||||
("issue_status_category__in", "new,indeterminate", {"TEST-1"}),
|
||||
("project_key", "TEST", {"TEST-1", "TEST-2"}),
|
||||
("search", "TEST-1", {"TEST-1"}),
|
||||
],
|
||||
)
|
||||
def test_filters(
|
||||
self,
|
||||
authenticated_client,
|
||||
jira_issues_fixture,
|
||||
filter_name,
|
||||
filter_value,
|
||||
expected_keys,
|
||||
):
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"), {f"filter[{filter_name}]": filter_value}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
keys = {item["attributes"]["issue_key"] for item in response.json()["data"]}
|
||||
assert keys == expected_keys
|
||||
|
||||
def test_filter_by_provider(self, authenticated_client, jira_issues_fixture):
|
||||
linked, other_provider_issue, _ = jira_issues_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"),
|
||||
{"filter[provider_id]": str(other_provider_issue.provider_id)},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [
|
||||
str(other_provider_issue.id)
|
||||
]
|
||||
|
||||
def test_filter_by_integration_and_finding_id(
|
||||
self, authenticated_client, jira_issues_fixture
|
||||
):
|
||||
linked, *_ = jira_issues_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"),
|
||||
{
|
||||
"filter[integration]": str(linked.integration_id),
|
||||
"filter[finding_id]": str(linked.finding_id),
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
|
||||
|
||||
def test_invalid_filter(self, authenticated_client, jira_issues_fixture):
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"), {"filter[invalid]": "x"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_include_provider(self, authenticated_client, jira_issues_fixture):
|
||||
response = authenticated_client.get(
|
||||
reverse("jiraissue-list"), {"include": "provider"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
included_types = {item["type"] for item in response.json()["included"]}
|
||||
assert included_types == {"providers"}
|
||||
|
||||
def test_read_only(self, authenticated_client, jira_issues_fixture):
|
||||
linked, *_ = jira_issues_fixture
|
||||
response = authenticated_client.post(
|
||||
reverse("jiraissue-list"),
|
||||
data=json.dumps({"data": {"type": "jira-issues", "attributes": {}}}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
response = authenticated_client.delete(
|
||||
reverse("jiraissue-detail", kwargs={"pk": linked.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestIntegrationViewSet:
|
||||
def test_integrations_list(self, authenticated_client, integrations_fixture):
|
||||
|
||||
@@ -14,6 +14,7 @@ from api.models import (
|
||||
IntegrationProviderRelationship,
|
||||
Invitation,
|
||||
InvitationRoleRelationship,
|
||||
JiraIssue,
|
||||
LighthouseConfiguration,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
@@ -4149,6 +4150,41 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
|
||||
# Mute Rules
|
||||
|
||||
|
||||
class JiraIssueSerializer(RLSSerializer):
|
||||
"""
|
||||
Read-only view of a Jira issue linked to a finding by a Jira integration.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = JiraIssue
|
||||
fields = [
|
||||
"id",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"finding_uid",
|
||||
"finding_id",
|
||||
"issue_key",
|
||||
"issue_id",
|
||||
"issue_url",
|
||||
"project_key",
|
||||
"issue_status",
|
||||
"issue_status_category",
|
||||
"status_synced_at",
|
||||
"integration",
|
||||
"provider",
|
||||
"url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
included_serializers = {
|
||||
"provider": "api.v1.serializers.ProviderIncludeSerializer",
|
||||
}
|
||||
|
||||
|
||||
class MuteRuleSerializer(RLSSerializer):
|
||||
"""
|
||||
Serializer for reading MuteRule instances.
|
||||
|
||||
@@ -14,6 +14,7 @@ from api.v1.views import (
|
||||
IntegrationViewSet,
|
||||
InvitationAcceptViewSet,
|
||||
InvitationViewSet,
|
||||
JiraIssueViewSet,
|
||||
LighthouseConfigViewSet,
|
||||
LighthouseProviderConfigViewSet,
|
||||
LighthouseProviderModelsViewSet,
|
||||
@@ -107,6 +108,7 @@ router.register(
|
||||
basename="lighthouse-models",
|
||||
)
|
||||
router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule")
|
||||
router.register(r"jira-issues", JiraIssueViewSet, basename="jiraissue")
|
||||
|
||||
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
|
||||
tenants_router.register(
|
||||
|
||||
@@ -54,6 +54,7 @@ from api.filters import (
|
||||
IntegrationFilter,
|
||||
IntegrationJiraFindingsFilter,
|
||||
InvitationFilter,
|
||||
JiraIssueFilter,
|
||||
LatestFindingFilter,
|
||||
LatestFindingGroupFilter,
|
||||
LatestFindingGroupSummaryFilter,
|
||||
@@ -89,6 +90,7 @@ from api.models import (
|
||||
Integration,
|
||||
Invitation,
|
||||
InvitationRoleRelationship,
|
||||
JiraIssue,
|
||||
LighthouseConfiguration,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
@@ -179,6 +181,7 @@ from api.v1.serializers import (
|
||||
InvitationCreateSerializer,
|
||||
InvitationSerializer,
|
||||
InvitationUpdateSerializer,
|
||||
JiraIssueSerializer,
|
||||
LighthouseConfigCreateSerializer,
|
||||
LighthouseConfigSerializer,
|
||||
LighthouseConfigUpdateSerializer,
|
||||
@@ -7488,6 +7491,56 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
|
||||
return Response(data=serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# Jira issues
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="List Jira issues linked to findings",
|
||||
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=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Retrieve a Jira issue link",
|
||||
description="Fetch the Jira issue linked to a finding by the link ID.",
|
||||
),
|
||||
)
|
||||
class JiraIssueViewSet(BaseRLSViewSet):
|
||||
queryset = JiraIssue.objects.all()
|
||||
serializer_class = JiraIssueSerializer
|
||||
filterset_class = JiraIssueFilter
|
||||
http_method_names = ["get"]
|
||||
search_fields = ["finding_uid", "issue_key"]
|
||||
ordering = ["-inserted_at"]
|
||||
ordering_fields = [
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"issue_key",
|
||||
"project_key",
|
||||
"issue_status",
|
||||
"status_synced_at",
|
||||
]
|
||||
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enables unlimited
|
||||
# visibility or check visibility via provider group, like findings)
|
||||
required_permissions = []
|
||||
|
||||
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=""
|
||||
)
|
||||
if not self.user_role.unlimited_visibility:
|
||||
queryset = queryset.filter(provider__in=get_providers(self.user_role))
|
||||
return queryset.select_related("provider", "integration")
|
||||
|
||||
|
||||
# MuteRules
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ from api.models import (
|
||||
Integration,
|
||||
IntegrationProviderRelationship,
|
||||
Invitation,
|
||||
JiraIssue,
|
||||
LighthouseConfiguration,
|
||||
Membership,
|
||||
MuteRule,
|
||||
@@ -1470,6 +1471,52 @@ 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."""
|
||||
provider, provider2 = aws_provider_pair
|
||||
finding1, finding2 = findings_fixture
|
||||
tenant_id = jira_integration_fixture.tenant_id
|
||||
with rls_transaction(str(tenant_id)):
|
||||
linked = JiraIssue.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider=provider,
|
||||
finding_uid=finding1.uid,
|
||||
finding_id=finding1.id,
|
||||
issue_key="TEST-1",
|
||||
issue_id="10001",
|
||||
issue_url="https://test.atlassian.net/browse/TEST-1",
|
||||
project_key="TEST",
|
||||
issue_status="To Do",
|
||||
issue_status_category=JiraIssue.StatusCategoryChoices.NEW,
|
||||
status_synced_at=datetime.now(UTC),
|
||||
)
|
||||
hidden_provider_issue = JiraIssue.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider=provider2,
|
||||
finding_uid="test_finding_uid_other_provider",
|
||||
finding_id=finding2.id,
|
||||
issue_key="TEST-2",
|
||||
issue_id="10002",
|
||||
issue_url="https://test.atlassian.net/browse/TEST-2",
|
||||
project_key="TEST",
|
||||
issue_status="Done",
|
||||
issue_status_category=JiraIssue.StatusCategoryChoices.DONE,
|
||||
status_synced_at=datetime.now(UTC),
|
||||
)
|
||||
reservation = JiraIssue.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider=provider,
|
||||
finding_uid=finding2.uid,
|
||||
finding_id=finding2.id,
|
||||
project_key="TEST",
|
||||
)
|
||||
return linked, hidden_provider_issue, reservation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backfill_scan_metadata_fixture(scans_fixture, findings_fixture):
|
||||
for scan_instance in scans_fixture:
|
||||
|
||||
@@ -5,6 +5,7 @@ from api.db_utils import batch_delete, rls_transaction
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
JiraIssue,
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
@@ -86,6 +87,7 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
|
||||
deletion_steps = [
|
||||
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
|
||||
("Jira Issues", JiraIssue.objects.filter(provider=instance)),
|
||||
("Findings", Finding.all_objects.filter(scan__provider=instance)),
|
||||
("Resources", Resource.all_objects.filter(provider=instance)),
|
||||
("Scans", Scan.all_objects.filter(provider=instance)),
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
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.models import Finding, Integration, JiraIssue, 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.db import OperationalError
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, OperationalError
|
||||
from django.utils import timezone
|
||||
from prowler.lib.outputs.asff.asff import ASFF
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.csv.csv import CSV
|
||||
@@ -477,6 +481,257 @@ 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 ""
|
||||
|
||||
|
||||
# Findings are pre-checked against existing Jira issues in chunks so the IN list
|
||||
# stays bounded however many findings a dispatch carries
|
||||
JIRA_DEDUP_CHUNK_SIZE = 500
|
||||
# A reservation (row without issue key) older than this belongs to a run that
|
||||
# died mid-send and can be reclaimed
|
||||
JIRA_RESERVATION_TTL = timedelta(minutes=15)
|
||||
# Cap on the per-finding detail returned in the task result; counts are exact
|
||||
JIRA_SKIPPED_REPORT_LIMIT = 100
|
||||
|
||||
|
||||
def _load_finding_refs(finding_ids: list[str]) -> dict[str, tuple[str, str]]:
|
||||
"""Map finding id -> (provider id, finding uid) for the batch, in one query."""
|
||||
refs = {}
|
||||
for finding_id, provider_id, uid in Finding.all_objects.filter(
|
||||
id__in=finding_ids
|
||||
).values_list("id", "scan__provider_id", "uid"):
|
||||
refs[str(finding_id)] = (str(provider_id), uid)
|
||||
return refs
|
||||
|
||||
|
||||
def _load_existing_jira_issues(
|
||||
tenant_id: str, integration_id: str, refs: dict[str, tuple[str, str]]
|
||||
) -> dict[tuple[str, str], JiraIssue]:
|
||||
"""Load the Jira issue rows already linked to the batch's findings.
|
||||
|
||||
Grouped by provider and chunked so each query is a bounded index lookup on
|
||||
(tenant, integration, provider, finding_uid).
|
||||
"""
|
||||
uids_by_provider: dict[str, list[str]] = {}
|
||||
for provider_id, uid in refs.values():
|
||||
uids_by_provider.setdefault(provider_id, []).append(uid)
|
||||
|
||||
existing: dict[tuple[str, str], JiraIssue] = {}
|
||||
for provider_id, uids in uids_by_provider.items():
|
||||
for start in range(0, len(uids), JIRA_DEDUP_CHUNK_SIZE):
|
||||
chunk = uids[start : start + JIRA_DEDUP_CHUNK_SIZE]
|
||||
for row in JiraIssue.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
integration_id=integration_id,
|
||||
provider_id=provider_id,
|
||||
finding_uid__in=chunk,
|
||||
):
|
||||
existing[(str(row.provider_id), row.finding_uid)] = row
|
||||
return existing
|
||||
|
||||
|
||||
def _refresh_jira_issue_statuses(
|
||||
tenant_id: str, jira_integration, rows: list[JiraIssue]
|
||||
) -> dict[str, dict] | None:
|
||||
"""Fetch the current Jira status of linked rows and cache it on them.
|
||||
|
||||
Returns the statuses keyed by issue key (keys missing from the result no
|
||||
longer exist in Jira), or None when Jira could not be queried, in which case
|
||||
the cached values are left untouched.
|
||||
"""
|
||||
keys = [row.issue_key for row in rows if row.issue_key]
|
||||
if not keys:
|
||||
return {}
|
||||
try:
|
||||
statuses = jira_integration.get_issues_status(keys)
|
||||
except JiraBaseException as error:
|
||||
logger.warning(
|
||||
"Could not refresh Jira issue statuses, keeping cached values: %s",
|
||||
error.message or error,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("Could not refresh Jira issue statuses, keeping cached values")
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
for row in rows:
|
||||
status = statuses.get(row.issue_key)
|
||||
if status is None:
|
||||
# The issue is gone: keep the key for reference but mark it as done so
|
||||
# the next send creates a fresh issue
|
||||
row.issue_status = ""
|
||||
row.issue_status_category = JiraIssue.StatusCategoryChoices.DONE
|
||||
else:
|
||||
row.issue_status = status.get("status", "")[:64]
|
||||
row.issue_status_category = status.get("status_category", "")[:16]
|
||||
row.status_synced_at = now
|
||||
with rls_transaction(tenant_id):
|
||||
JiraIssue.objects.bulk_update(
|
||||
rows, ["issue_status", "issue_status_category", "status_synced_at"]
|
||||
)
|
||||
return statuses
|
||||
|
||||
|
||||
def _reserve_jira_issue(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
provider_id: str,
|
||||
finding_uid: str,
|
||||
finding_id: str,
|
||||
project_key: str,
|
||||
) -> JiraIssue | None:
|
||||
"""Claim the (integration, provider, finding uid) slot before calling Jira.
|
||||
|
||||
The unique constraint makes this the arbiter between concurrent runs: only
|
||||
the run that inserts the row (or reclaims an expired reservation) sends the
|
||||
finding. Returns None when another run owns the slot.
|
||||
"""
|
||||
with rls_transaction(tenant_id):
|
||||
try:
|
||||
row, created = JiraIssue.objects.get_or_create(
|
||||
tenant_id=tenant_id,
|
||||
integration_id=integration_id,
|
||||
provider_id=provider_id,
|
||||
finding_uid=finding_uid,
|
||||
defaults={"finding_id": finding_id, "project_key": project_key},
|
||||
)
|
||||
except IntegrityError:
|
||||
return None
|
||||
if created:
|
||||
return row
|
||||
if row.issue_key:
|
||||
# Linked by a concurrent run between the pre-check and now
|
||||
return None
|
||||
if timezone.now() - row.updated_at < JIRA_RESERVATION_TTL:
|
||||
# Another run is sending this finding right now
|
||||
return None
|
||||
# Expired reservation from a run that died mid-send: reclaim it
|
||||
row.finding_id = finding_id
|
||||
row.project_key = project_key
|
||||
row.save(update_fields=["finding_id", "project_key", "updated_at"])
|
||||
return row
|
||||
|
||||
|
||||
def _link_jira_issue(
|
||||
tenant_id: str, row: JiraIssue, issue: dict, finding_id: str, project_key: str
|
||||
) -> None:
|
||||
"""Point the row at the issue Jira just created."""
|
||||
with rls_transaction(tenant_id):
|
||||
row.issue_key = (issue.get("key") or "")[:64]
|
||||
row.issue_id = str(issue.get("id") or "")[:64]
|
||||
row.issue_url = (issue.get("url") or "")[:2048]
|
||||
row.project_key = project_key
|
||||
row.finding_id = finding_id
|
||||
row.issue_status = ""
|
||||
row.issue_status_category = JiraIssue.StatusCategoryChoices.NEW
|
||||
row.status_synced_at = None
|
||||
row.save(
|
||||
update_fields=[
|
||||
"issue_key",
|
||||
"issue_id",
|
||||
"issue_url",
|
||||
"project_key",
|
||||
"finding_id",
|
||||
"issue_status",
|
||||
"issue_status_category",
|
||||
"status_synced_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _release_jira_issue(tenant_id: str, row: JiraIssue) -> None:
|
||||
"""Drop a reservation whose send failed so the finding can be retried."""
|
||||
if row.issue_key:
|
||||
# A previously linked (now closed) issue stays linked; the send failed so
|
||||
# there is nothing newer to point at
|
||||
return
|
||||
with rls_transaction(tenant_id):
|
||||
JiraIssue.objects.filter(id=row.id, issue_key="").delete()
|
||||
|
||||
|
||||
def _skipped_entry(finding_id: str, row: JiraIssue) -> dict:
|
||||
return {
|
||||
"finding_id": str(finding_id),
|
||||
"issue_key": row.issue_key,
|
||||
"issue_url": row.issue_url,
|
||||
"issue_status": row.issue_status,
|
||||
}
|
||||
|
||||
|
||||
def send_findings_to_jira(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
@@ -484,13 +739,50 @@ def send_findings_to_jira(
|
||||
issue_type: str,
|
||||
finding_ids: list[str],
|
||||
):
|
||||
"""Create one Jira issue per finding, skipping findings that already have one.
|
||||
|
||||
Findings are matched to existing issues by (integration, provider, finding
|
||||
uid), so a finding that was already sent in a previous scan is recognised.
|
||||
Findings whose linked issue is still open are skipped and reported; findings
|
||||
whose issue is closed or was deleted in Jira get a new issue that replaces
|
||||
the link.
|
||||
"""
|
||||
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)
|
||||
finding_refs = _load_finding_refs(finding_ids)
|
||||
existing = _load_existing_jira_issues(tenant_id, integration_id, finding_refs)
|
||||
|
||||
# Refresh the status of the linked issues in bulk so closed/deleted ones can be
|
||||
# replaced. If Jira cannot be queried the linked findings are skipped as-is.
|
||||
linked_rows = [row for row in existing.values() if row.issue_key]
|
||||
statuses = (
|
||||
_refresh_jira_issue_statuses(tenant_id, jira_integration, linked_rows)
|
||||
if linked_rows
|
||||
else {}
|
||||
)
|
||||
|
||||
num_tickets_created = 0
|
||||
error_messages = []
|
||||
skipped: list[dict] = []
|
||||
error_messages: list[str] = []
|
||||
created_rows: list[JiraIssue] = []
|
||||
for finding_id in finding_ids:
|
||||
finding_id = str(finding_id)
|
||||
provider_id, finding_uid = finding_refs.get(finding_id, (None, None))
|
||||
row = existing.get((provider_id, finding_uid)) if provider_id else None
|
||||
if row is not None:
|
||||
if row.issue_key:
|
||||
if statuses is None or not row.is_done:
|
||||
# Still open (or status unknown): already ticketed
|
||||
skipped.append(_skipped_entry(finding_id, row))
|
||||
continue
|
||||
# Closed or deleted in Jira: create a replacement below
|
||||
elif timezone.now() - row.updated_at < JIRA_RESERVATION_TTL:
|
||||
# Another run is sending this finding right now
|
||||
skipped.append(_skipped_entry(finding_id, row))
|
||||
continue
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
finding_instance = (
|
||||
Finding.all_objects.select_related("scan__provider")
|
||||
@@ -519,53 +811,99 @@ def send_findings_to_jira(
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_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 {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
except JiraBaseException as error:
|
||||
error_message = error.message or JIRA_GENERIC_SEND_ERROR
|
||||
logger.exception(
|
||||
"Failed to send finding %s to Jira: %s", finding_id, error_message
|
||||
)
|
||||
error_messages.append(error_message)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to send finding %s to Jira", finding_id)
|
||||
error_messages.append(JIRA_GENERIC_SEND_ERROR)
|
||||
provider_type = finding_instance.scan.provider.provider
|
||||
if provider_id is None:
|
||||
provider_id = str(finding_instance.scan.provider_id)
|
||||
finding_uid = finding_instance.uid
|
||||
issue_labels = build_jira_issue_labels(
|
||||
finding_uid=finding_uid,
|
||||
provider=provider_type,
|
||||
severity=finding_instance.severity,
|
||||
check_id=finding_instance.check_id,
|
||||
)
|
||||
finding_url = build_jira_finding_url(finding_uid)
|
||||
|
||||
if row is None:
|
||||
row = _reserve_jira_issue(
|
||||
tenant_id,
|
||||
integration_id,
|
||||
provider_id,
|
||||
finding_uid,
|
||||
finding_id,
|
||||
project_key,
|
||||
)
|
||||
if row is None:
|
||||
skipped.append({"finding_id": finding_id})
|
||||
continue
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
else:
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
error_messages.append(error_message)
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_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=provider_type,
|
||||
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 {},
|
||||
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
|
||||
logger.exception(
|
||||
"Failed to send finding %s to Jira: %s", finding_id, error_message
|
||||
)
|
||||
error_messages.append(error_message)
|
||||
_release_jira_issue(tenant_id, row)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to send finding %s to Jira", finding_id)
|
||||
error_messages.append(JIRA_GENERIC_SEND_ERROR)
|
||||
_release_jira_issue(tenant_id, row)
|
||||
continue
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
issue = result if isinstance(result, dict) else {}
|
||||
logger.info(
|
||||
"Finding %s sent to Jira as %s", finding_id, issue.get("key") or result
|
||||
)
|
||||
_link_jira_issue(tenant_id, row, issue, finding_id, project_key)
|
||||
created_rows.append(row)
|
||||
else:
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
error_messages.append(error_message)
|
||||
_release_jira_issue(tenant_id, row)
|
||||
|
||||
# Record the initial status of the issues just created, in bulk
|
||||
if created_rows:
|
||||
_refresh_jira_issue_statuses(
|
||||
tenant_id, jira_integration, [row for row in created_rows if row.issue_key]
|
||||
)
|
||||
|
||||
result = {
|
||||
"created_count": num_tickets_created,
|
||||
"failed_count": len(finding_ids) - num_tickets_created,
|
||||
"skipped_count": len(skipped),
|
||||
"failed_count": len(finding_ids) - num_tickets_created - len(skipped),
|
||||
}
|
||||
if skipped:
|
||||
result["skipped"] = skipped[:JIRA_SKIPPED_REPORT_LIMIT]
|
||||
if error_messages:
|
||||
result["error"] = "; ".join(dict.fromkeys(error_messages))
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant, TenantComplianceSummary
|
||||
from api.models import JiraIssue, Provider, Tenant, TenantComplianceSummary
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
@@ -33,6 +33,22 @@ class TestDeleteProvider:
|
||||
str(instance.id),
|
||||
)
|
||||
|
||||
def test_delete_provider_removes_jira_issues(self, jira_issues_fixture):
|
||||
linked, other_provider_issue, reservation = jira_issues_fixture
|
||||
provider = linked.provider
|
||||
tenant_id = str(provider.tenant_id)
|
||||
with (
|
||||
patch("tasks.jobs.deletion.graph_database.get_database_name"),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
|
||||
):
|
||||
delete_provider(tenant_id, provider.id)
|
||||
|
||||
remaining = set(JiraIssue.objects.values_list("id", flat=True))
|
||||
assert linked.id not in remaining
|
||||
assert reservation.id not in remaining
|
||||
# Issues of other providers are untouched
|
||||
assert other_provider_issue.id in remaining
|
||||
|
||||
def test_delete_provider_does_not_exist(self, tenants_fixture):
|
||||
with (
|
||||
patch(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from datetime import UTC, datetime
|
||||
import itertools
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.models import Integration
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Integration, JiraIssue
|
||||
from api.utils import prowler_integration_connection_test
|
||||
from django.db import OperationalError
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraRefreshTokenError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
@@ -13,8 +17,14 @@ 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 (
|
||||
JIRA_RESERVATION_TTL,
|
||||
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,
|
||||
@@ -1650,6 +1660,27 @@ class TestSecurityHubIntegrationUploads:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJiraIntegration:
|
||||
"""Sending findings to Jira, with the dedup bookkeeping stubbed out.
|
||||
|
||||
These tests use fake tenant ids and fully mocked findings; the dedup helpers
|
||||
are exercised with real rows in TestJiraIssueDedup.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_dedup_bookkeeping(self):
|
||||
reservation = MagicMock()
|
||||
reservation.issue_key = ""
|
||||
with patch.multiple(
|
||||
"tasks.jobs.integrations",
|
||||
_load_finding_refs=MagicMock(return_value={}),
|
||||
_load_existing_jira_issues=MagicMock(return_value={}),
|
||||
_refresh_jira_issue_statuses=MagicMock(return_value={}),
|
||||
_reserve_jira_issue=MagicMock(return_value=reservation),
|
||||
_link_jira_issue=MagicMock(),
|
||||
_release_jira_issue=MagicMock(),
|
||||
):
|
||||
yield
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@@ -1696,6 +1727,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 +1756,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,12 +1781,16 @@ 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}
|
||||
assert result == {"created_count": 2, "skipped_count": 0, "failed_count": 0}
|
||||
|
||||
# Verify Jira integration was initialized
|
||||
mock_initialize_integration.assert_called_once_with(integration)
|
||||
@@ -1773,12 +1810,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")
|
||||
@@ -1849,6 +1910,7 @@ class TestJiraIntegration:
|
||||
# Assertions
|
||||
assert result == {
|
||||
"created_count": 2,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
@@ -1915,6 +1977,7 @@ class TestJiraIntegration:
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
@@ -1983,6 +2046,7 @@ class TestJiraIntegration:
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
@@ -2047,6 +2111,7 @@ class TestJiraIntegration:
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
@@ -2124,7 +2189,7 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 1, "failed_count": 0}
|
||||
assert result == {"created_count": 1, "skipped_count": 0, "failed_count": 0}
|
||||
|
||||
# Verify send_finding was called with empty resource fields
|
||||
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
|
||||
@@ -2187,7 +2252,7 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 1, "failed_count": 0}
|
||||
assert result == {"created_count": 1, "skipped_count": 0, "failed_count": 0}
|
||||
|
||||
# Verify send_finding was called with default/empty values
|
||||
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
|
||||
@@ -2200,3 +2265,360 @@ 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") == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJiraIssueDedup:
|
||||
"""send_findings_to_jira with real JiraIssue rows: skip, replace, reserve."""
|
||||
|
||||
@pytest.fixture
|
||||
def jira_mock(self):
|
||||
jira = MagicMock()
|
||||
counter = itertools.count(1)
|
||||
|
||||
def _create_issue(**kwargs):
|
||||
number = next(counter)
|
||||
return {
|
||||
"key": f"TEST-{number}",
|
||||
"id": str(10000 + number),
|
||||
"url": f"https://test.atlassian.net/browse/TEST-{number}",
|
||||
}
|
||||
|
||||
jira.send_finding.side_effect = _create_issue
|
||||
jira.get_issues_status.return_value = {}
|
||||
return jira
|
||||
|
||||
@pytest.fixture
|
||||
def send(self, jira_mock, jira_integration_fixture):
|
||||
def _send(finding_ids):
|
||||
with patch(
|
||||
"tasks.jobs.integrations.initialize_prowler_integration",
|
||||
return_value=jira_mock,
|
||||
):
|
||||
return send_findings_to_jira(
|
||||
str(jira_integration_fixture.tenant_id),
|
||||
str(jira_integration_fixture.id),
|
||||
"TEST",
|
||||
"Task",
|
||||
[str(finding_id) for finding_id in finding_ids],
|
||||
)
|
||||
|
||||
return _send
|
||||
|
||||
@staticmethod
|
||||
def _rows(integration):
|
||||
with rls_transaction(str(integration.tenant_id)):
|
||||
return {
|
||||
row.finding_uid: row
|
||||
for row in JiraIssue.objects.filter(integration=integration)
|
||||
}
|
||||
|
||||
def test_first_send_links_findings(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, finding2 = findings_fixture
|
||||
jira_mock.get_issues_status.return_value = {
|
||||
"TEST-1": {"id": "10001", "status": "To Do", "status_category": "new"},
|
||||
"TEST-2": {"id": "10002", "status": "To Do", "status_category": "new"},
|
||||
}
|
||||
|
||||
result = send([finding1.id, finding2.id])
|
||||
|
||||
assert result == {"created_count": 2, "skipped_count": 0, "failed_count": 0}
|
||||
assert jira_mock.send_finding.call_count == 2
|
||||
rows = self._rows(jira_integration_fixture)
|
||||
assert set(rows) == {finding1.uid, finding2.uid}
|
||||
row = rows[finding1.uid]
|
||||
assert row.issue_key == "TEST-1"
|
||||
assert row.issue_id == "10001"
|
||||
assert row.issue_url == "https://test.atlassian.net/browse/TEST-1"
|
||||
assert row.project_key == "TEST"
|
||||
assert row.finding_id == finding1.id
|
||||
assert row.provider_id == finding1.scan.provider_id
|
||||
# Status of the new issues is fetched once, in bulk, after creation
|
||||
jira_mock.get_issues_status.assert_called_once_with(["TEST-1", "TEST-2"])
|
||||
assert row.issue_status == "To Do"
|
||||
assert row.issue_status_category == "new"
|
||||
assert row.status_synced_at is not None
|
||||
|
||||
def test_second_send_skips_open_issue(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
send([finding1.id])
|
||||
jira_mock.send_finding.reset_mock()
|
||||
jira_mock.get_issues_status.reset_mock()
|
||||
jira_mock.get_issues_status.return_value = {
|
||||
"TEST-1": {
|
||||
"id": "10001",
|
||||
"status": "In Progress",
|
||||
"status_category": "indeterminate",
|
||||
}
|
||||
}
|
||||
|
||||
result = send([finding1.id])
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"skipped_count": 1,
|
||||
"failed_count": 0,
|
||||
"skipped": [
|
||||
{
|
||||
"finding_id": str(finding1.id),
|
||||
"issue_key": "TEST-1",
|
||||
"issue_url": "https://test.atlassian.net/browse/TEST-1",
|
||||
"issue_status": "In Progress",
|
||||
}
|
||||
],
|
||||
}
|
||||
jira_mock.send_finding.assert_not_called()
|
||||
jira_mock.get_issues_status.assert_called_once_with(["TEST-1"])
|
||||
row = self._rows(jira_integration_fixture)[finding1.uid]
|
||||
assert row.issue_key == "TEST-1"
|
||||
# The refreshed status is cached on the row
|
||||
assert row.issue_status == "In Progress"
|
||||
assert row.issue_status_category == "indeterminate"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_response",
|
||||
[
|
||||
{"TEST-1": {"id": "10001", "status": "Done", "status_category": "done"}},
|
||||
{}, # deleted in Jira
|
||||
],
|
||||
ids=["closed", "deleted"],
|
||||
)
|
||||
def test_closed_or_deleted_issue_is_replaced(
|
||||
self,
|
||||
send,
|
||||
jira_mock,
|
||||
jira_integration_fixture,
|
||||
findings_fixture,
|
||||
status_response,
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
send([finding1.id])
|
||||
jira_mock.send_finding.reset_mock()
|
||||
jira_mock.get_issues_status.return_value = status_response
|
||||
|
||||
result = send([finding1.id])
|
||||
|
||||
assert result == {"created_count": 1, "skipped_count": 0, "failed_count": 0}
|
||||
jira_mock.send_finding.assert_called_once()
|
||||
rows = self._rows(jira_integration_fixture)
|
||||
assert len(rows) == 1
|
||||
assert rows[finding1.uid].issue_key == "TEST-2"
|
||||
assert (
|
||||
rows[finding1.uid].issue_url == "https://test.atlassian.net/browse/TEST-2"
|
||||
)
|
||||
|
||||
def test_status_lookup_failure_skips_linked_findings(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
send([finding1.id])
|
||||
jira_mock.send_finding.reset_mock()
|
||||
jira_mock.get_issues_status.side_effect = JiraRefreshTokenError(
|
||||
message="token expired"
|
||||
)
|
||||
|
||||
result = send([finding1.id])
|
||||
|
||||
assert result["created_count"] == 0
|
||||
assert result["skipped_count"] == 1
|
||||
jira_mock.send_finding.assert_not_called()
|
||||
# Cached status untouched
|
||||
row = self._rows(jira_integration_fixture)[finding1.uid]
|
||||
assert row.issue_key == "TEST-1"
|
||||
|
||||
def test_failed_send_releases_reservation(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
jira_mock.send_finding.side_effect = JiraRequiredCustomFieldsError(
|
||||
message="custom fields"
|
||||
)
|
||||
|
||||
result = send([finding1.id])
|
||||
|
||||
assert result["created_count"] == 0
|
||||
assert result["failed_count"] == 1
|
||||
assert result["error"] == "custom fields"
|
||||
assert self._rows(jira_integration_fixture) == {}
|
||||
|
||||
def test_failed_replacement_keeps_previous_link(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
send([finding1.id])
|
||||
jira_mock.get_issues_status.return_value = {
|
||||
"TEST-1": {"id": "10001", "status": "Done", "status_category": "done"}
|
||||
}
|
||||
jira_mock.send_finding.side_effect = Exception("boom")
|
||||
|
||||
result = send([finding1.id])
|
||||
|
||||
assert result["failed_count"] == 1
|
||||
row = self._rows(jira_integration_fixture)[finding1.uid]
|
||||
assert row.issue_key == "TEST-1"
|
||||
assert row.issue_status_category == "done"
|
||||
|
||||
def test_fresh_reservation_is_skipped_and_stale_one_reclaimed(
|
||||
self, send, jira_mock, jira_integration_fixture, findings_fixture
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
tenant_id = str(jira_integration_fixture.tenant_id)
|
||||
with rls_transaction(tenant_id):
|
||||
reservation = JiraIssue.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=jira_integration_fixture,
|
||||
provider_id=finding1.scan.provider_id,
|
||||
finding_uid=finding1.uid,
|
||||
finding_id=finding1.id,
|
||||
project_key="TEST",
|
||||
)
|
||||
|
||||
# Another run is sending this finding right now
|
||||
result = send([finding1.id])
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"skipped_count": 1,
|
||||
"failed_count": 0,
|
||||
"skipped": [
|
||||
{
|
||||
"finding_id": str(finding1.id),
|
||||
"issue_key": "",
|
||||
"issue_url": "",
|
||||
"issue_status": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
jira_mock.send_finding.assert_not_called()
|
||||
|
||||
# The run died: the reservation expired and is reclaimed
|
||||
with rls_transaction(tenant_id):
|
||||
JiraIssue.objects.filter(id=reservation.id).update(
|
||||
updated_at=timezone.now() - JIRA_RESERVATION_TTL - timedelta(minutes=1)
|
||||
)
|
||||
result = send([finding1.id])
|
||||
assert result == {"created_count": 1, "skipped_count": 0, "failed_count": 0}
|
||||
rows = self._rows(jira_integration_fixture)
|
||||
assert len(rows) == 1
|
||||
assert rows[finding1.uid].id == reservation.id
|
||||
assert rows[finding1.uid].issue_key == "TEST-1"
|
||||
|
||||
def test_other_integration_does_not_dedup(
|
||||
self,
|
||||
send,
|
||||
jira_mock,
|
||||
jira_integration_fixture,
|
||||
findings_fixture,
|
||||
tenants_fixture,
|
||||
):
|
||||
finding1, _ = findings_fixture
|
||||
tenant_id = str(jira_integration_fixture.tenant_id)
|
||||
with rls_transaction(tenant_id):
|
||||
other = Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.JIRA,
|
||||
configuration={"projects": {"OTHER": "Other"}},
|
||||
credentials={
|
||||
"domain": "other",
|
||||
"user_mail": "a@b.com",
|
||||
"api_token": "t",
|
||||
},
|
||||
)
|
||||
send([finding1.id])
|
||||
jira_mock.send_finding.reset_mock()
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.integrations.initialize_prowler_integration",
|
||||
return_value=jira_mock,
|
||||
):
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, str(other.id), "OTHER", "Task", [str(finding1.id)]
|
||||
)
|
||||
|
||||
assert result["created_count"] == 1
|
||||
jira_mock.send_finding.assert_called_once()
|
||||
with rls_transaction(tenant_id):
|
||||
assert JiraIssue.objects.filter(finding_uid=finding1.uid).count() == 2
|
||||
|
||||
Generated
+61
-2
@@ -4836,7 +4836,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=23e048fcd20738b68b54be6adc8b43f8a26d0d43#23e048fcd20738b68b54be6adc8b43f8a26d0d43" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4928,9 +4928,11 @@ dependencies = [
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-objectstorage" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "stackit-ske" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5035,7 +5037,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=23e048fcd20738b68b54be6adc8b43f8a26d0d43" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -6117,6 +6119,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-ske"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
@@ -6621,6 +6638,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.2"
|
||||
|
||||
@@ -124,6 +124,25 @@ To manually send individual Findings to Jira:
|
||||
|
||||

|
||||
|
||||
### 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-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`. 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.
|
||||
|
||||
### 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:
|
||||
|
||||
* 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.
|
||||
|
||||
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.
|
||||
|
||||
## Integration Status
|
||||
|
||||
Monitor and manage your Jira integrations through the management interface:
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ResourceDrawerFinding {
|
||||
resourceDetails: string | null;
|
||||
resourceMetadata: Record<string, unknown> | string | null;
|
||||
// Provider
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
@@ -280,6 +281,7 @@ export function adaptFindingsByResourceResponse(
|
||||
| null
|
||||
| undefined) ?? null,
|
||||
// Provider
|
||||
providerId: providerRelId ?? "",
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
providerAlias: (providerAttrs.alias as string | undefined) || "",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { adaptJiraIssues } from "./jira-issues.adapter";
|
||||
|
||||
describe("adaptJiraIssues", () => {
|
||||
it("returns an empty list for a missing or malformed document", () => {
|
||||
expect(adaptJiraIssues(undefined)).toEqual([]);
|
||||
expect(adaptJiraIssues({})).toEqual([]);
|
||||
expect(adaptJiraIssues({ data: undefined })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps attributes and relationships to the domain shape", () => {
|
||||
const [link] = adaptJiraIssues({
|
||||
data: [
|
||||
{
|
||||
type: "jira-issues",
|
||||
id: "link-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-25T10:00:00Z",
|
||||
updated_at: "2026-08-25T10:00:00Z",
|
||||
finding_uid: "uid-1",
|
||||
finding_id: "finding-1",
|
||||
issue_key: "SEC-1",
|
||||
issue_id: "10001",
|
||||
issue_url: "",
|
||||
project_key: "SEC",
|
||||
issue_status: "",
|
||||
issue_status_category: "done",
|
||||
status_synced_at: null,
|
||||
},
|
||||
relationships: { provider: { data: null } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(link).toEqual({
|
||||
id: "link-1",
|
||||
findingUid: "uid-1",
|
||||
findingId: "finding-1",
|
||||
issueKey: "SEC-1",
|
||||
issueUrl: null,
|
||||
projectKey: "SEC",
|
||||
issueStatus: null,
|
||||
issueStatusCategory: "done",
|
||||
statusSyncedAt: null,
|
||||
insertedAt: "2026-08-25T10:00:00Z",
|
||||
providerId: null,
|
||||
integrationId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
JIRA_ISSUE_STATUS_CATEGORY,
|
||||
type JiraIssueLink,
|
||||
type JiraIssueStatusCategory,
|
||||
} from "@/types/integrations";
|
||||
|
||||
import type { JiraIssuesResponse } from "./jira-issues.types";
|
||||
|
||||
const STATUS_CATEGORIES: readonly string[] = Object.values(
|
||||
JIRA_ISSUE_STATUS_CATEGORY,
|
||||
);
|
||||
|
||||
const toStatusCategory = (value: unknown): JiraIssueStatusCategory | null =>
|
||||
typeof value === "string" && STATUS_CATEGORIES.includes(value)
|
||||
? (value as JiraIssueStatusCategory)
|
||||
: null;
|
||||
|
||||
export const adaptJiraIssues = (
|
||||
response: JiraIssuesResponse | undefined,
|
||||
): JiraIssueLink[] => {
|
||||
const data = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
return data.flatMap((resource) => {
|
||||
const attributes = resource.attributes;
|
||||
// A row without a key is a reservation still being created; the API hides
|
||||
// them, but never render one if it slips through.
|
||||
if (!attributes?.issue_key) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: resource.id,
|
||||
findingUid: attributes.finding_uid,
|
||||
findingId: attributes.finding_id,
|
||||
issueKey: attributes.issue_key,
|
||||
issueUrl: attributes.issue_url || null,
|
||||
projectKey: attributes.project_key,
|
||||
issueStatus: attributes.issue_status || null,
|
||||
issueStatusCategory: toStatusCategory(attributes.issue_status_category),
|
||||
statusSyncedAt: attributes.status_synced_at ?? null,
|
||||
insertedAt: attributes.inserted_at,
|
||||
providerId: resource.relationships?.provider?.data?.id ?? null,
|
||||
integrationId: resource.relationships?.integration?.data?.id ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
import { getJiraIssuesForFindings } from "./jira-issues";
|
||||
|
||||
const PROVIDER_ID = "0f6d1c0e-8a2c-4f0e-9b0e-4c1a2b3c4d5e";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
const issue = (attributes: Record<string, unknown> = {}) => ({
|
||||
type: "jira-issues",
|
||||
id: "link-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-25T10:00:00Z",
|
||||
updated_at: "2026-08-25T10:00:00Z",
|
||||
finding_uid: "prowler-aws-check-1",
|
||||
finding_id: "finding-1",
|
||||
issue_key: "SEC-1",
|
||||
issue_id: "10001",
|
||||
issue_url: "https://acme.atlassian.net/browse/SEC-1",
|
||||
project_key: "SEC",
|
||||
issue_status: "To Do",
|
||||
issue_status_category: "new",
|
||||
status_synced_at: "2026-08-25T10:00:05Z",
|
||||
...attributes,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: PROVIDER_ID } },
|
||||
integration: { data: { type: "integrations", id: "integration-1" } },
|
||||
},
|
||||
});
|
||||
|
||||
const lastFetchUrl = (): URL => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return new URL(String(call[0]));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: [] }));
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("getJiraIssuesForFindings", () => {
|
||||
it("returns nothing without fetching for an empty query", async () => {
|
||||
// When
|
||||
const result = await getJiraIssuesForFindings({ findingUids: [] });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ issues: [], unavailable: false });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by finding uid and provider, deduplicating uids", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: [issue()] }));
|
||||
|
||||
// When
|
||||
const result = await getJiraIssuesForFindings({
|
||||
findingUids: ["prowler-aws-check-1", "prowler-aws-check-1", "other"],
|
||||
providerId: PROVIDER_ID,
|
||||
});
|
||||
|
||||
// Then
|
||||
const url = lastFetchUrl();
|
||||
expect(url.pathname).toBe("/api/v1/jira-issues");
|
||||
expect(url.searchParams.get("filter[finding_uid__in]")).toBe(
|
||||
"prowler-aws-check-1,other",
|
||||
);
|
||||
expect(url.searchParams.get("filter[provider_id]")).toBe(PROVIDER_ID);
|
||||
expect(url.searchParams.get("page[size]")).toBe("2");
|
||||
expect(result.unavailable).toBe(false);
|
||||
expect(result.issues).toEqual([
|
||||
{
|
||||
id: "link-1",
|
||||
findingUid: "prowler-aws-check-1",
|
||||
findingId: "finding-1",
|
||||
issueKey: "SEC-1",
|
||||
issueUrl: "https://acme.atlassian.net/browse/SEC-1",
|
||||
projectKey: "SEC",
|
||||
issueStatus: "To Do",
|
||||
issueStatusCategory: "new",
|
||||
statusSyncedAt: "2026-08-25T10:00:05Z",
|
||||
insertedAt: "2026-08-25T10:00:00Z",
|
||||
providerId: PROVIDER_ID,
|
||||
integrationId: "integration-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades to unavailable when the endpoint does not exist", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 404));
|
||||
|
||||
// When
|
||||
const result = await getJiraIssuesForFindings({ findingUids: ["uid"] });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ issues: [], unavailable: true });
|
||||
});
|
||||
|
||||
it("degrades to unavailable on a network error", async () => {
|
||||
// Given
|
||||
fetchMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
// When
|
||||
const result = await getJiraIssuesForFindings({ findingUids: ["uid"] });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ issues: [], unavailable: true });
|
||||
});
|
||||
|
||||
it("ignores rows without an issue key and unknown status categories", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
issue({ issue_key: "" }),
|
||||
issue({ issue_status_category: "weird", issue_url: "" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await getJiraIssuesForFindings({ findingUids: ["uid"] });
|
||||
|
||||
// Then
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].issueStatusCategory).toBeNull();
|
||||
expect(result.issues[0].issueUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import type { JiraIssueLinksResult } from "@/types/integrations";
|
||||
|
||||
import { adaptJiraIssues } from "./jira-issues.adapter";
|
||||
import type { JiraIssuesResponse } from "./jira-issues.types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
// One request covers a drawer (1 uid) or a page of findings; the API caps the
|
||||
// page size, so larger batches are split by the caller.
|
||||
const MAX_FINDING_UIDS_PER_REQUEST = 100;
|
||||
|
||||
const jiraIssuesQuerySchema = z.object({
|
||||
findingUids: z
|
||||
.array(z.string().trim().min(1))
|
||||
.min(1)
|
||||
.max(MAX_FINDING_UIDS_PER_REQUEST),
|
||||
providerId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export type JiraIssuesQuery = z.infer<typeof jiraIssuesQuerySchema>;
|
||||
|
||||
/**
|
||||
* Jira issues linked to the given findings, keyed by finding UID.
|
||||
*
|
||||
* Never throws: an older API without the endpoint, a network error or a
|
||||
* timeout all resolve to `unavailable: true` so callers can render nothing.
|
||||
*/
|
||||
export const getJiraIssuesForFindings = async (
|
||||
query: JiraIssuesQuery,
|
||||
): Promise<JiraIssueLinksResult> => {
|
||||
const parsed = jiraIssuesQuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
return { issues: [], unavailable: false };
|
||||
}
|
||||
|
||||
const { findingUids, providerId } = parsed.data;
|
||||
const uniqueUids = Array.from(new Set(findingUids));
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/jira-issues`);
|
||||
url.searchParams.set("filter[finding_uid__in]", uniqueUids.join(","));
|
||||
if (providerId) {
|
||||
url.searchParams.set("filter[provider_id]", providerId);
|
||||
}
|
||||
url.searchParams.set("page[size]", String(uniqueUids.length));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { issues: [], unavailable: true };
|
||||
}
|
||||
|
||||
const body = (await response.json()) as JiraIssuesResponse;
|
||||
return { issues: adaptJiraIssues(body), unavailable: false };
|
||||
} catch (error) {
|
||||
console.error("Error fetching the Jira issues linked to findings:", error);
|
||||
return { issues: [], unavailable: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
|
||||
|
||||
export interface JiraIssueAttributes {
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
finding_uid: string;
|
||||
finding_id: string;
|
||||
issue_key: string;
|
||||
issue_id: string;
|
||||
issue_url: string;
|
||||
project_key: string;
|
||||
issue_status: string;
|
||||
issue_status_category: string;
|
||||
status_synced_at: string | null;
|
||||
}
|
||||
|
||||
interface JsonApiRelationshipRef {
|
||||
data?: { type: string; id: string } | null;
|
||||
}
|
||||
|
||||
export type JiraIssueResource = JsonApiResource<JiraIssueAttributes> & {
|
||||
relationships?: {
|
||||
provider?: JsonApiRelationshipRef;
|
||||
integration?: JsonApiRelationshipRef;
|
||||
};
|
||||
};
|
||||
|
||||
export type JiraIssuesResponse = JsonApiDocument<JiraIssueResource[]>;
|
||||
@@ -0,0 +1 @@
|
||||
Findings show their linked Jira issue (key, link and last known status) in the detail drawer, and sending to Jira reports findings that already have an open issue instead of creating duplicates
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import {
|
||||
JIRA_ISSUE_STATUS_CATEGORY,
|
||||
type JiraIssueLink,
|
||||
type JiraIssueStatusCategory,
|
||||
} from "@/types/integrations";
|
||||
|
||||
type JiraIssueStatusVariant = "info" | "warning" | "success";
|
||||
|
||||
const CATEGORY_VARIANT: Record<
|
||||
JiraIssueStatusCategory,
|
||||
JiraIssueStatusVariant
|
||||
> = {
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.NEW]: "info",
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.INDETERMINATE]: "warning",
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.DONE]: "success",
|
||||
} as const;
|
||||
|
||||
const CATEGORY_LABEL: Record<JiraIssueStatusCategory, string> = {
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.NEW]: "To Do",
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.INDETERMINATE]: "In Progress",
|
||||
[JIRA_ISSUE_STATUS_CATEGORY.DONE]: "Done",
|
||||
} as const;
|
||||
|
||||
interface JiraIssueStatusBadgeProps {
|
||||
issue: Pick<JiraIssueLink, "issueStatus" | "issueStatusCategory">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last status Prowler observed for a linked Jira issue. Falls back to the
|
||||
* category label when Jira's status name is unknown, and to a neutral badge
|
||||
* when neither was synced yet.
|
||||
*/
|
||||
export const JiraIssueStatusBadge = ({ issue }: JiraIssueStatusBadgeProps) => {
|
||||
const category = issue.issueStatusCategory;
|
||||
const label =
|
||||
issue.issueStatus || (category ? CATEGORY_LABEL[category] : "Unknown");
|
||||
const variant = category ? CATEGORY_VARIANT[category] : "tag";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -65,6 +65,7 @@ export function FindingDetailDrawer({
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
jiraIssue={drawer.jiraIssue}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
@@ -91,6 +92,7 @@ export function FindingDetailDrawer({
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
jiraIssue={drawer.jiraIssue}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
|
||||
@@ -277,6 +277,7 @@ export function FindingsGroupDrillDown({
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
jiraIssue={drawer.jiraIssue}
|
||||
showSyntheticResourceHint={group.resourcesTotal === 0}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
|
||||
@@ -457,6 +457,7 @@ export function InlineResourceContainer({
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
jiraIssue={drawer.jiraIssue}
|
||||
showSyntheticResourceHint={group.resourcesTotal === 0}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
|
||||
+83
@@ -551,6 +551,7 @@ import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import type { JiraIssueLink } from "@/types/integrations";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -655,6 +656,7 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
@@ -2241,3 +2243,84 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — linked Jira issue", () => {
|
||||
const renderWithJiraIssue = (
|
||||
jiraIssue: JiraIssueLink | null,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
jiraIssue={jiraIssue}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("should render the issue key as a link with its last known status", () => {
|
||||
// When
|
||||
renderWithJiraIssue({
|
||||
id: "link-1",
|
||||
findingUid: mockFinding.uid,
|
||||
findingId: mockFinding.id,
|
||||
issueKey: "SEC-42",
|
||||
issueUrl: "https://acme.atlassian.net/browse/SEC-42",
|
||||
projectKey: "SEC",
|
||||
issueStatus: "In Progress",
|
||||
issueStatusCategory: "indeterminate",
|
||||
statusSyncedAt: "2026-08-25T10:00:00Z",
|
||||
insertedAt: "2026-08-25T09:00:00Z",
|
||||
providerId: "provider-1",
|
||||
integrationId: "integration-1",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Jira issue")).toBeInTheDocument();
|
||||
const link = screen.getByRole("link", { name: /SEC-42/ });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://acme.atlassian.net/browse/SEC-42",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to the category label and a plain key without a URL", () => {
|
||||
// When
|
||||
renderWithJiraIssue({
|
||||
id: "link-2",
|
||||
findingUid: mockFinding.uid,
|
||||
findingId: mockFinding.id,
|
||||
issueKey: "SEC-7",
|
||||
issueUrl: null,
|
||||
projectKey: "SEC",
|
||||
issueStatus: null,
|
||||
issueStatusCategory: "done",
|
||||
statusSyncedAt: null,
|
||||
insertedAt: "2026-08-25T09:00:00Z",
|
||||
providerId: null,
|
||||
integrationId: null,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("SEC-7")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /SEC-7/ })).toBeNull();
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing about Jira when the finding has no linked issue", () => {
|
||||
// When
|
||||
renderWithJiraIssue(null);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Jira issue")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+42
@@ -25,6 +25,7 @@ import {
|
||||
requestPanelSkillLaunch,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { JiraIssueStatusBadge } from "@/components/findings/jira-issue-status-badge";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
@@ -88,6 +89,7 @@ import type {
|
||||
FindingTriageUpdateResult,
|
||||
UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
import type { JiraIssueLink } from "@/types/integrations";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
@@ -295,6 +297,8 @@ interface ResourceDetailDrawerContentProps {
|
||||
currentResource?: FindingResourceRow | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
/** Jira issue linked to the current finding, when one exists. */
|
||||
jiraIssue?: JiraIssueLink | null;
|
||||
showSyntheticResourceHint?: boolean;
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
@@ -311,6 +315,7 @@ export function ResourceDetailDrawerContent({
|
||||
currentResource = null,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
jiraIssue = null,
|
||||
showSyntheticResourceHint = false,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
@@ -973,6 +978,43 @@ export function ResourceDetailDrawerContent({
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
{jiraIssue && (
|
||||
<InfoField
|
||||
label="Jira issue"
|
||||
variant="compact"
|
||||
tooltipContent={
|
||||
jiraIssue.statusSyncedAt
|
||||
? "Status as last seen by Prowler; open the issue for the current state."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{jiraIssue.issueUrl ? (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={jiraIssue.issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
prefetch={false}
|
||||
>
|
||||
{jiraIssue.issueKey}
|
||||
<ExternalLink
|
||||
className="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<CodeSnippet
|
||||
value={jiraIssue.issueKey}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
<JiraIssueStatusBadge issue={jiraIssue} />
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -178,6 +178,7 @@ function drawerFinding(
|
||||
resourceGroup: "storage",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "Production",
|
||||
providerUid: "123456789012",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
|
||||
import { buildFocusedFindingContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import type { JiraIssueLink } from "@/types/integrations";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -22,6 +23,7 @@ interface ResourceDetailDrawerProps {
|
||||
currentResource: FindingResourceRow | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
jiraIssue?: JiraIssueLink | null;
|
||||
showSyntheticResourceHint?: boolean;
|
||||
// Forwarded to DetailSidePanel: false opens the Details tab without
|
||||
// selecting it (skill launches keep the AI chat tab in front).
|
||||
@@ -43,6 +45,7 @@ export function ResourceDetailDrawer({
|
||||
currentResource,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
jiraIssue = null,
|
||||
showSyntheticResourceHint = false,
|
||||
selectTabOnOpen,
|
||||
onNavigatePrev,
|
||||
@@ -83,6 +86,7 @@ export function ResourceDetailDrawer({
|
||||
currentResource={currentResource}
|
||||
currentFinding={currentFinding}
|
||||
otherFindings={otherFindings}
|
||||
jiraIssue={jiraIssue}
|
||||
showSyntheticResourceHint={showSyntheticResourceHint}
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
|
||||
@@ -32,6 +32,13 @@ vi.mock("@/actions/findings", () => ({
|
||||
getFindingComplianceFrameworks: getFindingComplianceFrameworksMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/jira-issues", () => ({
|
||||
getJiraIssuesForFindings: vi.fn(async () => ({
|
||||
issues: [],
|
||||
unavailable: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
@@ -110,6 +117,7 @@ function makeDrawerFinding(
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
import { getJiraIssuesForFindings } from "@/actions/integrations/jira-issues";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import type { JiraIssueLink } from "@/types/integrations";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
// React doesn't batch away the skeleton frame when switching resources.
|
||||
@@ -96,6 +98,8 @@ interface UseResourceDetailDrawerOptions {
|
||||
|
||||
interface UseResourceDetailDrawerReturn {
|
||||
isOpen: boolean;
|
||||
/** Jira issue linked to the current finding; null when none (or unknown). */
|
||||
jiraIssue: JiraIssueLink | null;
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
checkMeta: CheckMeta | null;
|
||||
@@ -147,6 +151,10 @@ export function useResourceDetailDrawer({
|
||||
const otherFindingsCacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(
|
||||
new Map(),
|
||||
);
|
||||
const jiraIssueCacheRef = useRef<Map<string, JiraIssueLink | null>>(
|
||||
new Map(),
|
||||
);
|
||||
const [jiraIssue, setJiraIssue] = useState<JiraIssueLink | null>(null);
|
||||
// State, not a ref: the compliance frameworks land after the panel has
|
||||
// already painted, so the strip has to re-render on its own rather than
|
||||
// depend on some other setState happening to fire in the same tick.
|
||||
@@ -200,6 +208,7 @@ export function useResourceDetailDrawer({
|
||||
const resetCurrentResourceState = () => {
|
||||
setCurrentFinding(null);
|
||||
setOtherFindings([]);
|
||||
setJiraIssue(null);
|
||||
};
|
||||
|
||||
// Abort any in-flight request on unmount to prevent state updates
|
||||
@@ -292,6 +301,25 @@ export function useResourceDetailDrawer({
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const fetchJiraIssue = async (finding: ResourceDrawerFinding | null) => {
|
||||
if (!finding?.uid) return null;
|
||||
|
||||
const cached = jiraIssueCacheRef.current.get(findingId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { issues } = await getJiraIssuesForFindings({
|
||||
findingUids: [finding.uid],
|
||||
...(finding.providerId ? { providerId: finding.providerId } : {}),
|
||||
});
|
||||
// One integration may have ticketed the finding; show the most recent link
|
||||
const resolved = issues[0] ?? null;
|
||||
jiraIssueCacheRef.current.set(findingId, resolved);
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [nextCurrentFinding, nextOtherFindings] = await Promise.all([
|
||||
@@ -349,6 +377,18 @@ export function useResourceDetailDrawer({
|
||||
} catch (_error) {
|
||||
// Leaves the strip empty; the panel stays as it is.
|
||||
}
|
||||
|
||||
// Same reasoning as the compliance frameworks: supporting detail, fetched
|
||||
// after the panel painted, and never allowed to empty it.
|
||||
try {
|
||||
const linkedIssue = await fetchJiraIssue(
|
||||
currentFindingCacheRef.current.get(findingId) ?? null,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setJiraIssue(linkedIssue);
|
||||
} catch (_error) {
|
||||
// Leaves the Jira field out; the panel stays as it is.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -387,6 +427,7 @@ export function useResourceDetailDrawer({
|
||||
if (!resource) return;
|
||||
currentFindingCacheRef.current.delete(resource.findingId);
|
||||
complianceFrameworksCacheRef.current.delete(resource.findingId);
|
||||
jiraIssueCacheRef.current.delete(resource.findingId);
|
||||
otherFindingsCacheRef.current.delete(resource.resourceUid);
|
||||
startNavigation();
|
||||
resetCurrentResourceState();
|
||||
@@ -486,6 +527,7 @@ export function useResourceDetailDrawer({
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
jiraIssue,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch";
|
||||
import {
|
||||
evaluateJiraDispatchTask,
|
||||
getJiraDispatchSkippedCount,
|
||||
getJiraDispatchSuccessCount,
|
||||
} from "@/lib/jira-dispatch-result";
|
||||
import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
|
||||
@@ -29,6 +30,8 @@ export interface JiraDispatchExecutionResult {
|
||||
startedTaskCount: number;
|
||||
successfulTaskCount: number;
|
||||
successfulIssueCount: number;
|
||||
/** Findings that already had an open Jira issue and were not sent again. */
|
||||
skippedIssueCount: number;
|
||||
successMessage?: string;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
@@ -42,6 +45,7 @@ interface JiraTrackedOutcome {
|
||||
warning?: string;
|
||||
failedFindingIds?: string[];
|
||||
successfulCount?: number;
|
||||
skippedCount?: number;
|
||||
}
|
||||
|
||||
export function getJiraRetryBatch(
|
||||
@@ -142,6 +146,7 @@ export async function executeJiraDispatchBatches(
|
||||
warning: outcome.warning,
|
||||
failedFindingIds: outcome.failedFindingIds,
|
||||
successfulCount: getJiraDispatchSuccessCount(trackedTask.result),
|
||||
skippedCount: getJiraDispatchSkippedCount(trackedTask.result),
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}),
|
||||
);
|
||||
@@ -153,17 +158,28 @@ export async function executeJiraDispatchBatches(
|
||||
(count, outcome) => count + (outcome.successfulCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const skippedIssueCount = successfulOutcomes.reduce(
|
||||
(count, outcome) => count + (outcome.skippedCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const skippedSummary =
|
||||
skippedIssueCount > 0
|
||||
? ` ${skippedIssueCount} Finding${skippedIssueCount === 1 ? " already has" : "s already have"} an open Jira issue.`
|
||||
: "";
|
||||
const successMessage =
|
||||
successfulOutcomes.length === 1
|
||||
? successfulOutcomes[0].message
|
||||
: successfulOutcomes.length > 1
|
||||
? `${successfulIssueCount} Jira issues were created or updated successfully.`
|
||||
? successfulIssueCount > 0
|
||||
? `${successfulIssueCount} Jira issues were created or updated successfully.${skippedSummary}`
|
||||
: skippedSummary.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
startedTaskCount: startedTasks.length,
|
||||
successfulTaskCount: successfulOutcomes.length,
|
||||
successfulIssueCount,
|
||||
skippedIssueCount,
|
||||
successMessage,
|
||||
warnings: Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JiraDispatchTaskResult } from "@/types/integrations";
|
||||
|
||||
import {
|
||||
buildJiraDispatchSkippedMessage,
|
||||
evaluateJiraDispatchTask,
|
||||
getJiraDispatchSkippedCount,
|
||||
getJiraDispatchSuccessCount,
|
||||
} from "./jira-dispatch-result";
|
||||
|
||||
const skipped = (key: string, findingId = `finding-${key}`) => ({
|
||||
finding_id: findingId,
|
||||
issue_key: key,
|
||||
issue_url: `https://acme.atlassian.net/browse/${key}`,
|
||||
issue_status: "To Do",
|
||||
});
|
||||
|
||||
describe("getJiraDispatchSkippedCount", () => {
|
||||
it("uses the larger of the count and the list", () => {
|
||||
expect(getJiraDispatchSkippedCount(undefined)).toBe(0);
|
||||
expect(getJiraDispatchSkippedCount({ skipped_count: 2 })).toBe(2);
|
||||
expect(
|
||||
getJiraDispatchSkippedCount({
|
||||
skipped_count: 1,
|
||||
skipped: [skipped("SEC-1"), skipped("SEC-2")],
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJiraDispatchSkippedMessage", () => {
|
||||
it("is empty when nothing was skipped", () => {
|
||||
expect(buildJiraDispatchSkippedMessage({ created_count: 1 })).toBe("");
|
||||
});
|
||||
|
||||
it("names the existing issues, capped", () => {
|
||||
expect(
|
||||
buildJiraDispatchSkippedMessage({
|
||||
skipped_count: 1,
|
||||
skipped: [skipped("SEC-1")],
|
||||
}),
|
||||
).toBe("1 Finding already has an open Jira issue (SEC-1).");
|
||||
expect(
|
||||
buildJiraDispatchSkippedMessage({
|
||||
skipped_count: 5,
|
||||
skipped: [
|
||||
skipped("SEC-1"),
|
||||
skipped("SEC-2"),
|
||||
skipped("SEC-3"),
|
||||
skipped("SEC-4"),
|
||||
skipped("SEC-5"),
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
"5 Findings already have an open Jira issue (SEC-1, SEC-2, SEC-3, +2 more).",
|
||||
);
|
||||
});
|
||||
|
||||
it("works with a count but no keys (in-flight reservations)", () => {
|
||||
expect(
|
||||
buildJiraDispatchSkippedMessage({
|
||||
skipped_count: 1,
|
||||
skipped: [{ finding_id: "finding-1" }],
|
||||
}),
|
||||
).toBe("1 Finding already has an open Jira issue.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateJiraDispatchTask with skipped findings", () => {
|
||||
it("treats an all-skipped dispatch as success", () => {
|
||||
// Given
|
||||
const result: JiraDispatchTaskResult = {
|
||||
created_count: 0,
|
||||
skipped_count: 2,
|
||||
failed_count: 0,
|
||||
skipped: [skipped("SEC-1"), skipped("SEC-2")],
|
||||
};
|
||||
|
||||
// When
|
||||
const outcome = evaluateJiraDispatchTask("completed", result);
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({
|
||||
success: true,
|
||||
message: "2 Findings already have an open Jira issue (SEC-1, SEC-2).",
|
||||
skippedCount: 2,
|
||||
});
|
||||
expect(getJiraDispatchSuccessCount(result)).toBe(0);
|
||||
});
|
||||
|
||||
it("appends the skipped summary to a success message", () => {
|
||||
// Given
|
||||
const result: JiraDispatchTaskResult = {
|
||||
created_count: 3,
|
||||
skipped_count: 1,
|
||||
failed_count: 0,
|
||||
skipped: [skipped("SEC-9")],
|
||||
};
|
||||
|
||||
// When
|
||||
const outcome = evaluateJiraDispatchTask("completed", result);
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({
|
||||
success: true,
|
||||
message:
|
||||
"3 Jira issues were created or updated successfully. 1 Finding already has an open Jira issue (SEC-9).",
|
||||
skippedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a skipped-and-failed dispatch as a warning, not an error", () => {
|
||||
// Given
|
||||
const result: JiraDispatchTaskResult = {
|
||||
created_count: 0,
|
||||
skipped_count: 1,
|
||||
failed_count: 1,
|
||||
skipped: [skipped("SEC-1")],
|
||||
error: "Failed to create Jira issue.",
|
||||
};
|
||||
|
||||
// When
|
||||
const outcome = evaluateJiraDispatchTask("completed", result);
|
||||
|
||||
// Then
|
||||
expect(outcome.success).toBe(true);
|
||||
if (outcome.success) {
|
||||
expect(outcome.message).toBe(
|
||||
"1 Finding already has an open Jira issue (SEC-1).",
|
||||
);
|
||||
expect(outcome.warning).toContain("1 failed");
|
||||
expect(outcome.skippedCount).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("still fails when nothing was created nor skipped", () => {
|
||||
expect(
|
||||
evaluateJiraDispatchTask("completed", {
|
||||
created_count: 0,
|
||||
failed_count: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
error: "Jira dispatch completed but did not create or update any issues.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add skippedCount to plain successes", () => {
|
||||
expect(evaluateJiraDispatchTask("completed", { created_count: 1 })).toEqual(
|
||||
{
|
||||
success: true,
|
||||
message: "Finding successfully sent to Jira!",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,15 @@ export interface JiraDispatchSuccessOutcome {
|
||||
message: string;
|
||||
warning?: string;
|
||||
failedFindingIds?: string[];
|
||||
/** Findings left alone because they already had an open Jira issue. */
|
||||
skippedCount?: number;
|
||||
}
|
||||
|
||||
export interface JiraDispatchFailureOutcome {
|
||||
success: false;
|
||||
error: string;
|
||||
failedFindingIds?: string[];
|
||||
skippedCount?: number;
|
||||
}
|
||||
|
||||
export type JiraDispatchOutcome =
|
||||
@@ -52,6 +55,45 @@ export const getJiraDispatchSuccessCount = (
|
||||
);
|
||||
};
|
||||
|
||||
export const getJiraDispatchSkippedCount = (
|
||||
result: JiraDispatchTaskResult | undefined,
|
||||
) => {
|
||||
if (!result) return 0;
|
||||
return Math.max(result.skipped_count ?? 0, getArrayCount(result.skipped));
|
||||
};
|
||||
|
||||
const MAX_SKIPPED_KEYS_IN_MESSAGE = 3;
|
||||
|
||||
/**
|
||||
* "2 Findings already have an open Jira issue (SEC-1, SEC-2)." or an empty
|
||||
* string when nothing was skipped.
|
||||
*/
|
||||
export const buildJiraDispatchSkippedMessage = (
|
||||
result: JiraDispatchTaskResult | undefined,
|
||||
) => {
|
||||
const skippedCount = getJiraDispatchSkippedCount(result);
|
||||
if (skippedCount === 0) return "";
|
||||
|
||||
const keys = Array.from(
|
||||
new Set(
|
||||
(result?.skipped ?? []).flatMap((entry) =>
|
||||
entry.issue_key ? [entry.issue_key] : [],
|
||||
),
|
||||
),
|
||||
);
|
||||
const shownKeys = keys.slice(0, MAX_SKIPPED_KEYS_IN_MESSAGE);
|
||||
const hiddenCount = keys.length - shownKeys.length;
|
||||
const keysSuffix =
|
||||
shownKeys.length > 0
|
||||
? ` (${shownKeys.join(", ")}${hiddenCount > 0 ? `, +${hiddenCount} more` : ""})`
|
||||
: "";
|
||||
|
||||
return `${skippedCount} Finding${skippedCount === 1 ? " already has" : "s already have"} an open Jira issue${keysSuffix}.`;
|
||||
};
|
||||
|
||||
const withSkippedCount = (skippedCount: number) =>
|
||||
skippedCount > 0 ? { skippedCount } : {};
|
||||
|
||||
const ensureSentence = (message: string) =>
|
||||
/[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`;
|
||||
|
||||
@@ -67,11 +109,18 @@ const buildFailureMessage = (
|
||||
|
||||
const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => {
|
||||
const successCount = getJiraDispatchSuccessCount(result);
|
||||
if (successCount > 1) {
|
||||
return `${successCount} Jira issues were created or updated successfully.`;
|
||||
const skippedMessage = buildJiraDispatchSkippedMessage(result);
|
||||
|
||||
if (successCount === 0 && skippedMessage) {
|
||||
return skippedMessage;
|
||||
}
|
||||
|
||||
return "Finding successfully sent to Jira!";
|
||||
const created =
|
||||
successCount > 1
|
||||
? `${successCount} Jira issues were created or updated successfully.`
|
||||
: "Finding successfully sent to Jira!";
|
||||
|
||||
return skippedMessage ? `${created} ${skippedMessage}` : created;
|
||||
};
|
||||
|
||||
const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) =>
|
||||
@@ -86,17 +135,19 @@ export const evaluateJiraDispatchTask = (
|
||||
): JiraDispatchOutcome => {
|
||||
const jiraResult = result ?? undefined;
|
||||
const failedFindingIds = getFailedFindingIds(jiraResult);
|
||||
const skippedCount = getJiraDispatchSkippedCount(jiraResult);
|
||||
|
||||
if (state === "completed") {
|
||||
const failedCount = getFailedCount(jiraResult);
|
||||
if (failedCount > 0) {
|
||||
const successCount = getJiraDispatchSuccessCount(jiraResult);
|
||||
if (successCount > 0) {
|
||||
if (successCount > 0 || skippedCount > 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: buildSuccessMessage(jiraResult),
|
||||
warning: buildFailureMessage(jiraResult, failedCount),
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
...withSkippedCount(skippedCount),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,10 +163,14 @@ export const evaluateJiraDispatchTask = (
|
||||
success: false,
|
||||
error: jiraResult.error || "Failed to create Jira issue.",
|
||||
...withFailedFindingIds(failedFindingIds),
|
||||
...withSkippedCount(skippedCount),
|
||||
};
|
||||
}
|
||||
|
||||
if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
|
||||
if (
|
||||
!jiraResult ||
|
||||
(getJiraDispatchSuccessCount(jiraResult) === 0 && skippedCount === 0)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
@@ -123,9 +178,12 @@ export const evaluateJiraDispatchTask = (
|
||||
};
|
||||
}
|
||||
|
||||
// Every finding already had an open issue: nothing was created, and that
|
||||
// is the expected outcome rather than a failure.
|
||||
return {
|
||||
success: true,
|
||||
message: buildSuccessMessage(jiraResult),
|
||||
...withSkippedCount(skippedCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,49 @@ export interface JiraDispatchTaskResult {
|
||||
failed_finding_ids?: string[];
|
||||
issue_url?: string;
|
||||
issue_key?: string;
|
||||
/** Findings that already had an open Jira issue and were not sent again. */
|
||||
skipped_count?: number;
|
||||
skipped?: JiraSkippedIssue[];
|
||||
}
|
||||
|
||||
export interface JiraSkippedIssue {
|
||||
finding_id: string;
|
||||
issue_key?: string;
|
||||
issue_url?: string;
|
||||
issue_status?: string;
|
||||
}
|
||||
|
||||
// Jira issues linked to findings (GET /jira-issues)
|
||||
export const JIRA_ISSUE_STATUS_CATEGORY = {
|
||||
NEW: "new",
|
||||
INDETERMINATE: "indeterminate",
|
||||
DONE: "done",
|
||||
} as const;
|
||||
|
||||
export type JiraIssueStatusCategory =
|
||||
(typeof JIRA_ISSUE_STATUS_CATEGORY)[keyof typeof JIRA_ISSUE_STATUS_CATEGORY];
|
||||
|
||||
/** The latest Jira issue created for a finding, keyed by finding UID. */
|
||||
export interface JiraIssueLink {
|
||||
id: string;
|
||||
findingUid: string;
|
||||
findingId: string;
|
||||
issueKey: string;
|
||||
issueUrl: string | null;
|
||||
projectKey: string;
|
||||
/** Last status name observed in Jira, or null if never synced. */
|
||||
issueStatus: string | null;
|
||||
issueStatusCategory: JiraIssueStatusCategory | null;
|
||||
statusSyncedAt: string | null;
|
||||
insertedAt: string;
|
||||
providerId: string | null;
|
||||
integrationId: string | null;
|
||||
}
|
||||
|
||||
export interface JiraIssueLinksResult {
|
||||
issues: JiraIssueLink[];
|
||||
/** True when the API has no `/jira-issues` endpoint or could not be reached. */
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
// Shared AWS credential fields schema
|
||||
|
||||
Reference in New Issue
Block a user