Merge remote-tracking branch 'origin/master' into feature/eslint-typescript-flat

This commit is contained in:
Pablo F.G
2026-07-20 14:22:49 +02:00
17 changed files with 787 additions and 64 deletions
+4 -4
View File
@@ -123,12 +123,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
@@ -184,7 +184,7 @@ $combinedCsv | Export-Csv -Path "CombinedCSV.csv" -NoTypeInformation
## TODO: Additional Improvements
Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) isn't repeatedily instantiated by grouping dependant services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) aren't repeatedly instantiated by grouping dependent services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
* inspector2 needs lambda and ec2
* cloudwatch needs iam
+1 -1
View File
@@ -114,7 +114,7 @@ The CSV format follows a standardized structure across all providers. The follow
#### CSV Headers Mapping
The following table shows the mapping between the CSV headers and the the providers fields:
The following table shows the mapping between the CSV headers and the providers fields:
| Open Source Consolidated| AWS| GCP| AZURE| KUBERNETES
|----------|----------|----------|----------|----------
@@ -0,0 +1 @@
Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
+419 -51
View File
@@ -417,6 +417,19 @@ class Jira:
message=init_error, file=os.path.basename(__file__)
)
@staticmethod
def _sanitize_summary(summary: str) -> str:
"""Normalize and truncate a Jira issue summary.
Args:
summary: Raw summary text.
Returns:
The summary collapsed to one line and limited to Jira's 255-character
summary maximum.
"""
return " ".join(summary.split())[:255]
@staticmethod
def _build_code_block_content(code_value: str) -> Optional[Dict]:
if not code_value:
@@ -1155,6 +1168,101 @@ class Jira:
return "#0000FF"
return "#000000" # Default black color for unknown severities
@staticmethod
def _adf_colored_strong_marks(color_mark_type: str, color: str) -> list[dict]:
"""Build ADF marks for bold text with a Jira color mark.
Args:
color_mark_type: Jira ADF color mark type, such as textColor or
backgroundColor.
color: Hex color value for the mark.
Returns:
ADF marks for strong colored text.
"""
return [
{"type": "strong"},
{"type": color_mark_type, "attrs": {"color": color}},
]
def _adf_severity_marks(
self, severity: str = "", severity_color: str | None = None
) -> list[dict]:
"""Build ADF marks for severity text.
Args:
severity: Finding severity used to derive a color when severity_color
is not provided.
severity_color: Optional explicit severity color.
Returns:
ADF marks for highlighted severity text.
"""
color = severity_color or self.get_severity_color(str(severity).lower())
return self._adf_colored_strong_marks("backgroundColor", color)
def _adf_status_marks(
self, status: str = "", status_color: str | None = None
) -> list[dict]:
"""Build ADF marks for status text.
Args:
status: Finding status used to derive a color when status_color is
not provided.
status_color: Optional explicit status color.
Returns:
ADF marks for colored status text.
"""
color = status_color or self.get_color_from_status(str(status).upper())
return self._adf_colored_strong_marks("textColor", color)
@staticmethod
def _adf_text_node(text: str, marks: list[dict] | None = None) -> dict:
"""Build an ADF text node.
Args:
text: Text content for the node.
marks: Optional ADF marks to apply to the text.
Returns:
ADF text node with optional marks.
"""
node = {"type": "text", "text": text}
if marks:
node["marks"] = marks
return node
def _adf_severity_text_node(
self, severity: str = "", severity_color: str | None = None
) -> dict:
"""Build an ADF text node for severity.
Args:
severity: Severity text to render.
severity_color: Optional explicit severity color.
Returns:
ADF text node with severity marks.
"""
return self._adf_text_node(
severity, self._adf_severity_marks(severity, severity_color)
)
def _adf_status_text_node(
self, status: str = "", status_color: str | None = None
) -> dict:
"""Build an ADF text node for status.
Args:
status: Status text to render.
status_color: Optional explicit status color.
Returns:
ADF text node with status marks.
"""
return self._adf_text_node(status, self._adf_status_marks(status, status_color))
def get_adf_description(
self,
check_id: str = "",
@@ -1293,19 +1401,9 @@ class Jira:
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": severity,
"marks": [
{"type": "strong"},
{
"type": "backgroundColor",
"attrs": {
"color": severity_color,
},
},
],
}
self._adf_severity_text_node(
severity, severity_color
)
],
}
],
@@ -1338,17 +1436,7 @@ class Jira:
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": status,
"marks": [
{"type": "strong"},
{
"type": "textColor",
"attrs": {"color": status_color},
},
],
}
self._adf_status_text_node(status, status_color)
],
}
],
@@ -1872,6 +1960,239 @@ class Jira:
],
}
def get_grouped_adf_description(
self,
check_id: str = "",
check_title: str = "",
check_description: str = "",
severity: str = "",
status: str = "",
provider: str = "",
service: str = "",
affected_failing_resources: int = 0,
last_seen: str = "",
failing_for: str = "",
grouped_resources: list[dict] | None = None,
resources_total: int = 0,
resources_shown: int = 0,
finding_group_url: str = "",
finding_group_link_text: str = "",
risk: str = "",
recommendation_text: str = "",
recommendation_url: str = "",
) -> dict:
"""Build a Jira ADF description for a grouped finding issue.
Args:
check_id: Finding check ID.
check_title: Finding check title.
check_description: Finding check description.
severity: Finding group severity.
status: Finding group status.
provider: Cloud provider name.
service: Provider service name.
affected_failing_resources: Number of failing resources in the group.
last_seen: Last time the finding group was seen.
failing_for: Duration the finding group has been failing.
grouped_resources: Resource rows to include in the grouped issue.
resources_total: Total number of resources in the group.
resources_shown: Number of resources rendered in this Jira issue.
finding_group_url: Optional URL for the full finding group.
finding_group_link_text: Optional link text for finding_group_url.
risk: Risk description for the check.
recommendation_text: Remediation recommendation text.
recommendation_url: Optional remediation recommendation URL.
Returns:
Jira ADF document describing the finding group.
"""
def _safe(value) -> str:
return str(value) if value not in (None, "") else "-"
def _text(value, marks: list[dict] | None = None) -> dict:
node = {"type": "text", "text": _safe(value)}
if marks:
node["marks"] = marks
return node
def _paragraph(value, marks: list[dict] | None = None) -> dict:
return {"type": "paragraph", "content": [_text(value, marks)]}
def _cell(value, marks: list[dict] | None = None) -> dict:
return {"type": "tableCell", "content": [_paragraph(value, marks)]}
def _content_cell(content: list[dict]) -> dict:
return {"type": "tableCell", "content": content}
def _append_link(content: list[dict], url: str) -> list[dict]:
if not url:
return content
link_node = {
"type": "text",
"text": url,
"marks": [{"type": "link", "attrs": {"href": url}}],
}
if content and content[-1].get("type") == "paragraph":
paragraph_content = content[-1].setdefault("content", [])
if paragraph_content:
last_inline = paragraph_content[-1]
if last_inline.get("type") != "text" or not last_inline.get(
"text", ""
).endswith(" "):
paragraph_content.append({"type": "text", "text": " "})
paragraph_content.append(link_node)
else:
content.append({"type": "paragraph", "content": [link_node]})
return content
def _row(cells: list[dict]) -> dict:
return {"type": "tableRow", "content": cells}
strong = [{"type": "strong"}]
code = [{"type": "code"}]
severity_marks = self._adf_severity_marks(severity)
status_marks = self._adf_status_marks(status)
recommendation_content = _append_link(
self._markdown_converter.convert(_safe(recommendation_text)),
recommendation_url,
)
main_rows = [
_row([_cell("Check Id", strong), _cell(check_id, code)]),
_row([_cell("Check Title", strong), _cell(check_title)]),
_row([_cell("Severity", strong), _cell(severity, severity_marks)]),
_row([_cell("Status", strong), _cell(status, status_marks)]),
_row([_cell("Provider", strong), _cell(provider, code)]),
_row([_cell("Service", strong), _cell(service, code)]),
_row(
[
_cell("Affected Failing Resources", strong),
_cell(affected_failing_resources, strong),
]
),
_row([_cell("Last Seen", strong), _cell(last_seen)]),
_row([_cell("Failing For", strong), _cell(failing_for)]),
_row(
[
_cell("Risk", strong),
_content_cell(self._markdown_converter.convert(_safe(risk))),
]
),
_row(
[
_cell("Recommendation", strong),
_content_cell(recommendation_content),
]
),
]
resource_rows = [
_row(
[
_cell("Resource", strong),
_cell("Resource UID", strong),
_cell("Provider", strong),
_cell("Service", strong),
_cell("Account / Tenant", strong),
_cell("Status", strong),
_cell("Severity", strong),
_cell("Region", strong),
_cell("Last Seen", strong),
_cell("Failing For", strong),
_cell("Triage", strong),
]
)
]
for resource in grouped_resources or []:
resource_status = resource.get("status")
resource_severity = str(resource.get("severity", "")).upper()
resource_status_marks = self._adf_status_marks(resource_status)
resource_severity_marks = self._adf_severity_marks(resource_severity)
resource_rows.append(
_row(
[
_cell(resource.get("resource_name"), code),
_cell(resource.get("resource_uid"), code),
_cell(resource.get("provider"), code),
_cell(resource.get("service"), code),
_cell(resource.get("provider_account"), code),
_cell(resource_status, resource_status_marks),
_cell(resource_severity, resource_severity_marks),
_cell(resource.get("region"), code),
_cell(resource.get("last_seen")),
_cell(resource.get("failing_for")),
_cell(resource.get("triage")),
]
)
)
content = [
_paragraph("Prowler has discovered the following Finding Group:"),
{"type": "table", "attrs": {"layout": "full-width"}, "content": main_rows},
]
content.extend(
[
{
"type": "heading",
"attrs": {"level": 2},
"content": [_text("Affected failing resources")],
},
{
"type": "table",
"attrs": {"layout": "full-width"},
"content": resource_rows,
},
]
)
if resources_total > resources_shown:
remaining_content = [
_text(f"Showing {resources_shown} of {resources_total} Findings.")
]
if finding_group_url and finding_group_link_text:
remaining_content = [
_text(
f"Showing {resources_shown} of {resources_total} Findings "
"in this Jira issue. "
),
_text(
finding_group_link_text,
[
{
"type": "link",
"attrs": {"href": finding_group_url},
}
],
),
]
content.append(
{
"type": "paragraph",
"content": remaining_content,
}
)
elif finding_group_url and finding_group_link_text:
content.append(
{
"type": "paragraph",
"content": [
_text(
finding_group_link_text,
[
{
"type": "link",
"attrs": {"href": finding_group_url},
}
],
),
],
}
)
return {"type": "doc", "version": 1, "content": content}
def send_findings(
self,
findings: list[Finding] = None,
@@ -1965,7 +2286,7 @@ class Jira:
summary_parts.append(finding.resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"[:255]
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
payload = {
"fields": {
@@ -2048,11 +2369,13 @@ class Jira:
self,
check_id: str = "",
check_title: str = "",
check_description: str = "",
severity: str = "",
status: str = "",
status_extended: str = "",
provider: str = "",
region: str = "",
service: str = "",
resource_uid: str = "",
resource_name: str = "",
risk: str = "",
@@ -2069,6 +2392,14 @@ class Jira:
issue_labels: list[str] = "",
finding_url: str = "",
tenant_info: str = "",
affected_failing_resources: int = 0,
grouped_resources: list[dict] | None = None,
resources_total: int = 0,
resources_shown: int = 0,
last_seen: str = "",
failing_for: str = "",
finding_group_url: str = "",
finding_group_link_text: str = "",
) -> bool:
"""
Send the finding to Jira
@@ -2076,11 +2407,13 @@ class Jira:
Args:
- check_id: The check ID
- check_title: The check title
- check_description: The check description
- severity: The severity
- status: The status
- status_extended: The status extended
- provider: The provider
- region: The region
- service: The service
- resource_uid: The resource UID
- resource_name: The resource name
- risk: The risk
@@ -2097,6 +2430,15 @@ class Jira:
- issue_labels: The issue labels
- finding_url: The finding URL
- tenant_info: The tenant info
- affected_failing_resources: The number of affected failing resources
- grouped_resources: The grouped resources to render, or None for a
single finding issue
- resources_total: The total resources in the finding group
- resources_shown: The resources shown in the Jira issue
- last_seen: The last time the finding group was seen
- failing_for: The duration the finding group has been failing
- finding_group_url: The finding group URL
- finding_group_link_text: The link text for the finding group URL
Raises:
- JiraRefreshTokenError: Failed to refresh the access token
@@ -2140,40 +2482,66 @@ class Jira:
status_color = self.get_color_from_status(status)
severity_color = self.get_severity_color(severity.lower())
adf_description = self.get_adf_description(
check_id=check_id,
check_title=check_title,
severity=severity.upper(),
severity_color=severity_color,
status=status,
status_color=status_color,
status_extended=status_extended,
provider=provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
remediation_code_native_iac=remediation_code_native_iac,
remediation_code_terraform=remediation_code_terraform,
remediation_code_cli=remediation_code_cli,
remediation_code_other=remediation_code_other,
resource_tags=resource_tags,
compliance=compliance,
finding_url=finding_url,
tenant_info=tenant_info,
)
if grouped_resources is not None:
adf_description = self.get_grouped_adf_description(
check_id=check_id,
check_title=check_title,
check_description=check_description,
severity=severity.upper(),
status=status,
provider=provider,
service=service,
affected_failing_resources=affected_failing_resources,
last_seen=last_seen,
failing_for=failing_for,
grouped_resources=grouped_resources,
resources_total=resources_total,
resources_shown=resources_shown,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
)
else:
adf_description = self.get_adf_description(
check_id=check_id,
check_title=check_title,
severity=severity.upper(),
severity_color=severity_color,
status=status,
status_color=status_color,
status_extended=status_extended,
provider=provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
remediation_code_native_iac=remediation_code_native_iac,
remediation_code_terraform=remediation_code_terraform,
remediation_code_cli=remediation_code_cli,
remediation_code_other=remediation_code_other,
resource_tags=resource_tags,
compliance=compliance,
finding_url=finding_url,
tenant_info=tenant_info,
)
summary_parts = ["[Prowler]"]
if severity:
summary_parts.append(severity.upper())
if check_id:
summary_parts.append(check_id)
if resource_uid:
if grouped_resources is not None:
summary_parts.append(
f"{affected_failing_resources} affected failing resources"
)
elif resource_uid:
summary_parts.append(resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"[:255]
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
payload = {
"fields": {
+301
View File
@@ -98,6 +98,41 @@ class TestJiraIntegration:
return found
return None
@staticmethod
def _find_link_mark_by_href(nodes: List[dict], href: str) -> Optional[dict]:
for node in nodes:
if node.get("type") == "text":
for mark in node.get("marks", []):
if (
mark.get("type") == "link"
and mark.get("attrs", {}).get("href") == href
):
return mark
found = TestJiraIntegration._find_link_mark_by_href(
node.get("content", []), href
)
if found:
return found
return None
@staticmethod
def _collect_link_texts_by_href(nodes: List[dict], href: str) -> List[str]:
link_texts: List[str] = []
for node in nodes:
if node.get("type") == "text" and any(
mark.get("type") == "link" and mark.get("attrs", {}).get("href") == href
for mark in node.get("marks", [])
):
link_texts.append(node.get("text", ""))
link_texts.extend(
TestJiraIntegration._collect_link_texts_by_href(
node.get("content", []), href
)
)
return link_texts
@staticmethod
def _find_table_row(rows: List[dict], header: str) -> dict:
for row in rows:
@@ -918,6 +953,12 @@ class TestJiraIntegration:
intro_text = intro_paragraph["content"][0]
assert intro_text["type"] == "text"
assert intro_text["text"] == "Prowler has discovered the following finding:"
assert all(
self._collect_text_from_cell({"content": node.get("content", [])})
!= "Summary"
for node in description_content
if node.get("type") == "heading"
)
table = description_content[1]
assert table["type"] == "table"
@@ -1201,6 +1242,218 @@ class TestJiraIntegration:
value_cell = row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
def test_get_grouped_adf_description_uses_capped_finding_group_link_copy(self):
finding_group_url = (
"https://security.example.com/findings?"
"filter%5Bcheck_id%5D=admincenter_users_admins_reduced_license_footprint&"
"expandedCheckId=admincenter_users_admins_reduced_license_footprint"
)
finding_group_link_text = "View the remaining grouped findings."
recommendation_url = (
"https://hub.prowler.com/check/"
"admincenter_users_admins_reduced_license_footprint"
)
adf_description = self.jira_integration.get_grouped_adf_description(
check_id="admincenter_users_admins_reduced_license_footprint",
check_title="Administrative user has no license or an allowed license",
check_description="Administrative users are assigned productivity licenses.",
severity="HIGH",
status="FAIL",
provider="m365",
service="exchange",
affected_failing_resources=123,
last_seen="Jul 09, 2026 11:38AM UTC",
failing_for="< 1 day",
grouped_resources=[
{
"resource_name": "rich@prowler.com",
"resource_uid": "3f9a216b-b66b-4d5d-a812-2ad538732cfb",
"provider": "m365",
"service": "exchange",
"provider_account": "ProwlerPro.onmicrosoft.com",
"status": "FAIL",
"severity": "high",
"region": "global",
"last_seen": "Jul 09, 2026 11:38AM UTC",
"failing_for": "< 1 day",
"triage": "Open",
}
],
resources_total=123,
resources_shown=100,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
risk="Productivity licenses on privileged identities create risk.",
recommendation_text="Maintain dedicated admin accounts.",
recommendation_url=recommendation_url,
)
assert adf_description["type"] == "doc"
assert self._find_empty_text_nodes(adf_description) == []
main_table = adf_description["content"][1]
main_rows = {}
for row in main_table["content"]:
key_cell, value_cell = row["content"]
main_rows[self._collect_text_from_cell(key_cell)] = (
self._collect_text_from_cell(value_cell)
)
assert (
main_rows["Check Id"]
== "admincenter_users_admins_reduced_license_footprint"
)
assert main_rows["Service"] == "exchange"
assert main_rows["Affected Failing Resources"] == "123"
assert (
main_rows["Risk"]
== "Productivity licenses on privileged identities create risk."
)
assert main_rows["Recommendation"] == (
"Maintain dedicated admin accounts. " + recommendation_url
)
assert "Finding Group Link" not in main_rows
assert "Region" not in main_rows
top_level_headings = [
self._collect_text_from_cell({"content": node.get("content", [])})
for node in adf_description["content"]
if node.get("type") == "heading"
]
assert "Risk" not in top_level_headings
assert "Recommendation" not in top_level_headings
assert "Summary" not in top_level_headings
def text_marks(cell: dict) -> list[dict]:
return cell["content"][0]["content"][0]["marks"]
severity_marks = text_marks(
self._find_table_row(main_table["content"], "Severity")["content"][1]
)
status_marks = text_marks(
self._find_table_row(main_table["content"], "Status")["content"][1]
)
assert {
"type": "backgroundColor",
"attrs": {"color": "#FFA500"},
} in severity_marks
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in status_marks
resource_table = next(
node
for node in adf_description["content"]
if node.get("type") == "table"
and self._collect_text_from_cell(node["content"][0]["content"][0])
== "Resource"
)
resource_cells = resource_table["content"][1]["content"]
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in text_marks(
resource_cells[5]
)
assert {
"type": "backgroundColor",
"attrs": {"color": "#FFA500"},
} in text_marks(resource_cells[6])
document_text = self._collect_text_from_cell(
{"content": adf_description["content"]}
)
assert (
"Administrative users are assigned productivity licenses."
not in document_text
)
assert "Affected failing resources" in document_text
capped_link_copy = (
f"Showing 100 of 123 Findings in this Jira issue. {finding_group_link_text}"
)
assert document_text.count(capped_link_copy) == 1
assert "Finding Group Link" not in document_text
assert recommendation_url in document_text
recommendation_link_mark = self._find_link_mark_by_href(
adf_description["content"], recommendation_url
)
assert recommendation_link_mark is not None
link_mark = self._find_link_mark_by_href(
adf_description["content"], finding_group_url
)
assert link_mark is not None
assert link_mark["attrs"]["href"] == finding_group_url
assert self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
) == [finding_group_link_text]
assert (
len(
self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
)
)
== 1
)
assert "filter%5Bcheck_id%5D=" in link_mark["attrs"]["href"]
assert "expandedCheckId=" in link_mark["attrs"]["href"]
def test_get_grouped_adf_description_includes_link_when_not_capped(self):
finding_group_url = (
"https://security.example.com/findings?"
"filter%5Bcheck_id%5D=s3_bucket_public_access&"
"expandedCheckId=s3_bucket_public_access"
)
finding_group_link_text = "View this grouped finding."
adf_description = self.jira_integration.get_grouped_adf_description(
check_id="s3_bucket_public_access",
check_title="S3 bucket public access",
severity="HIGH",
status="FAIL",
provider="aws",
service="s3",
affected_failing_resources=1,
grouped_resources=[
{
"resource_name": "bucket-a",
"resource_uid": "arn:aws:s3:::bucket-a",
"provider": "aws",
"service": "s3",
"provider_account": "production (123456789012)",
"status": "FAIL",
"severity": "high",
"region": "us-east-1",
"last_seen": "Jul 09, 2026 11:38AM UTC",
"failing_for": "< 1 day",
"triage": "Open",
}
],
resources_total=1,
resources_shown=1,
finding_group_url=finding_group_url,
finding_group_link_text=finding_group_link_text,
)
document_text = self._collect_text_from_cell(
{"content": adf_description["content"]}
)
assert "Showing 1 of 1 Findings." not in document_text
assert "remaining Findings" not in document_text
assert document_text.count(finding_group_link_text) == 1
assert "Finding Group Link" not in document_text
main_table = adf_description["content"][1]
main_row_headers = [
self._collect_text_from_cell(row["content"][0])
for row in main_table["content"]
]
assert "Finding Group Link" not in main_row_headers
link_mark = self._find_link_mark_by_href(
adf_description["content"], finding_group_url
)
assert link_mark is not None
assert link_mark["attrs"]["href"] == finding_group_url
assert self._collect_link_texts_by_href(
adf_description["content"], finding_group_url
) == [finding_group_link_text]
assert (
"filter%5Bcheck_id%5D=s3_bucket_public_access" in link_mark["attrs"]["href"]
)
assert "expandedCheckId=s3_bucket_public_access" in link_mark["attrs"]["href"]
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
@@ -1709,6 +1962,54 @@ class TestJiraIntegration:
assert result is True
mock_post.assert_called_once()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_send_finding_sanitizes_summary_control_characters(
self,
mock_post,
mock_get_issue_types,
mock_get_projects,
mock_cloud_id,
mock_get_access_token,
):
"""Test that Jira summary is sent as one line."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_issue_types = mock_get_issue_types
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"}
mock_post.return_value = mock_response
long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260
result = self.jira_integration.send_finding(
check_id=long_check_id,
check_title="Test Finding",
severity="High\n",
status="FAIL",
project_key="TEST",
issue_type="Bug",
affected_failing_resources=2,
grouped_resources=[],
)
assert result is True
payload = mock_post.call_args.kwargs["json"]
expected_summary = (
f"[Prowler] HIGH - {' '.join(long_check_id.split())} - "
"2 affected failing resources"
)[:255]
assert payload["fields"]["summary"] == expected_summary
assert len(payload["fields"]["summary"]) == 255
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
@@ -0,0 +1 @@
Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
@@ -24,10 +24,15 @@ interface AppSidebarContentProps {
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const pathname = usePathname();
const { permissions } = useAuth();
const { apiDocsUrl } = useRuntimeConfig();
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useAppSidebarMode((state) => state.mode);
const isCloudEnvironment = isCloud();
const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
return (
@@ -157,6 +157,7 @@ describe("getNavigationConfig", () => {
const billing = getNavigationConfig({
pathname: "/billing",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions,
})
.flatMap((section) => section.items)
@@ -183,17 +184,28 @@ describe("getNavigationConfig", () => {
const cloudItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions,
}).flatMap((section) => section.items);
const enterpriseItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: false,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const localItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
cloudBillingEnabled: true,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
// Then
expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
expect(
enterpriseItems.find((item) => item.label === "Billing"),
).toBeUndefined();
expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
});
@@ -31,6 +31,7 @@ import {
interface NavigationConfigOptions {
pathname: string;
apiDocsUrl?: string | null;
cloudBillingEnabled?: boolean;
permissions?: RolePermissionAttributes;
}
@@ -106,6 +107,7 @@ export function filterNavigationByPermissions(
export function getNavigationConfig({
pathname,
apiDocsUrl = null,
cloudBillingEnabled = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
@@ -265,7 +267,7 @@ export function getNavigationConfig({
},
],
},
...(isCloudEnvironment
...(isCloudEnvironment && cloudBillingEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
+2
View File
@@ -105,6 +105,8 @@ describe("getRuntimeConfigClient", () => {
"reoDevClientId",
"sentryDsn",
"sentryEnvironment",
"stripePublishableKey",
"stripePublishableKeyV2",
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
+2
View File
@@ -21,6 +21,8 @@ const pickConfig = (
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
});
// Reads the <head> island once (memoized); all-null during SSR or if it's
+4
View File
@@ -10,6 +10,8 @@ export interface RuntimePublicConfig {
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
cloudBillingEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
}
export const RUNTIME_CONFIG_SCRIPT_ID = "__PROWLER_RUNTIME_CONFIG__";
@@ -25,4 +27,6 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
posthogHost: null,
reoDevClientId: null,
cloudBillingEnabled: false,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
+8
View File
@@ -49,5 +49,13 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
// server-side for V1/V2 routing). Default (unset) is off.
cloudBillingEnabled:
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false",
stripePublishableKey: readEnv(
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
),
stripePublishableKeyV2: readEnv(
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
),
};
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { readEnv } from "@/lib/runtime-env";
const publicRoutes = [
"/sign-in",
@@ -23,6 +24,8 @@ export default auth((req: NextAuthRequest) => {
const user = req.auth?.user;
const sessionError = req.auth?.error;
const cloudBillingEnabled =
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false";
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
if (sessionError && !isPublicRoute(pathname)) {
@@ -38,13 +41,16 @@ export default auth((req: NextAuthRequest) => {
return NextResponse.redirect(signInUrl);
}
if (
pathname.startsWith("/billing") &&
(!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
) {
return NextResponse.redirect(new URL("/profile", req.url));
}
if (user?.permissions) {
const permissions = user.permissions;
if (pathname.startsWith("/billing") && !permissions.manage_billing) {
return NextResponse.redirect(new URL("/profile", req.url));
}
if (
pathname.startsWith("/integrations") &&
!permissions.manage_integrations
@@ -20,6 +20,8 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogHost",
"reoDevClientId",
"cloudBillingEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
/**
+9
View File
@@ -30,6 +30,15 @@ declare global {
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
// Cloud-only Stripe publishable keys (public; shipped to the browser).
// V1 = legacy billing, V2 = metronome.
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY */
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?: string;
UI_CLOUD_STRIPE_PUBLISHABLE_KEY?: string;
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2 */
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2?: string;
UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2?: string;
// Build-time public config
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;