Skip to content

Commit 8e4c7c3

Browse files
committed
Merge remote-tracking branch 'origin/master' into fix/azure-vm-backup-case-insensitive-comparison
2 parents 0aeb1ee + ad6368a commit 8e4c7c3

File tree

13 files changed

+162
-54
lines changed

13 files changed

+162
-54
lines changed

.github/workflows/api-container-build-push.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ jobs:
9999
with:
100100
persist-credentials: false
101101

102+
- name: Pin prowler SDK to latest master commit
103+
if: github.event_name == 'push'
104+
run: |
105+
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1)
106+
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
107+
102108
- name: Login to DockerHub
103109
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
104110
with:

api/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,28 @@
22

33
All notable changes to the **Prowler API** are documented in this file.
44

5+
## [1.23.0] (Prowler UNRELEASED)
6+
7+
### 🔐 Security
8+
9+
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
10+
11+
---
12+
13+
## [1.22.1] (Prowler v5.21.1)
14+
15+
### 🐞 Fixed
16+
17+
- Threat score aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394)
18+
19+
---
20+
521
## [1.22.0] (Prowler v5.21.0)
622

723
### 🚀 Added
824

925
- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355)
26+
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
1027
- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
1128

1229
### 🔄 Changed
@@ -21,6 +38,7 @@ All notable changes to the **Prowler API** are documented in this file.
2138
### 🔐 Security
2239

2340
- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166)
41+
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
2442

2543
---
2644

api/poetry.lock

Lines changed: 21 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"drf-nested-routers (>=0.94.1,<1.0.0)",
2323
"drf-spectacular==0.27.2",
2424
"drf-spectacular-jsonapi==0.5.1",
25+
"defusedxml==0.7.1",
2526
"gunicorn==23.0.0",
2627
"lxml==5.3.2",
2728
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",

api/src/backend/api/filters.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,9 @@ class FindingGroupSummaryFilter(FilterSet):
926926
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
927927
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
928928
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
929+
check_title__icontains = CharFilter(
930+
field_name="check_title", lookup_expr="icontains"
931+
)
929932

930933
# Provider filters
931934
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
@@ -1025,6 +1028,9 @@ class LatestFindingGroupSummaryFilter(FilterSet):
10251028
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
10261029
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
10271030
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
1031+
check_title__icontains = CharFilter(
1032+
field_name="check_title", lookup_expr="icontains"
1033+
)
10281034

10291035
# Provider filters
10301036
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")

api/src/backend/api/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import json
22
import logging
33
import re
4-
import xml.etree.ElementTree as ET
54
from datetime import datetime, timedelta, timezone
65
from uuid import UUID, uuid4
76

87
from allauth.socialaccount.models import SocialApp
98
from config.custom_logging import BackendLogger
109
from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
1110
from cryptography.fernet import Fernet, InvalidToken
11+
import defusedxml
12+
from defusedxml import ElementTree as ET
1213
from django.conf import settings
1314
from django.contrib.auth.models import AbstractBaseUser
1415
from django.contrib.postgres.fields import ArrayField
@@ -2067,6 +2068,8 @@ def _parse_metadata(self):
20672068
root = ET.fromstring(self.metadata_xml)
20682069
except ET.ParseError as e:
20692070
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
2071+
except defusedxml.DefusedXmlException as e:
2072+
raise ValidationError({"metadata_xml": f"Unsafe XML content rejected: {e}"})
20702073

20712074
# Entity ID
20722075
entity_id = root.attrib.get("entityID")

api/src/backend/api/tests/integration/test_authentication.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ def test_switch_tenant_with_invalid_token(self, create_test_user, tenants_fixtur
301301
assert invalid_tenant_response.status_code == 400
302302
assert invalid_tenant_response.json()["errors"][0]["code"] == "invalid"
303303
assert invalid_tenant_response.json()["errors"][0]["detail"] == (
304-
"Tenant does not exist or user is not a " "member."
304+
"Tenant does not exist or user is not a member."
305305
)
306306

307307

@@ -912,10 +912,9 @@ def test_api_key_becomes_invalid_when_user_deleted(self, tenants_fixture):
912912
auth_response = client.get(reverse("provider-list"), headers=api_key_headers)
913913

914914
# Must return 401 Unauthorized, not 500 Internal Server Error
915-
assert auth_response.status_code == 401, (
916-
f"Expected 401 but got {auth_response.status_code}: "
917-
f"{auth_response.json()}"
918-
)
915+
assert (
916+
auth_response.status_code == 401
917+
), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}"
919918

920919
# Verify error message is present
921920
response_json = auth_response.json()

api/src/backend/api/tests/test_apps.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
import api
1111
import api.apps as api_apps_module
1212
from api.apps import (
13-
ApiConfig,
1413
PRIVATE_KEY_FILE,
1514
PUBLIC_KEY_FILE,
1615
SIGNING_KEY_ENV,
1716
VERIFYING_KEY_ENV,
17+
ApiConfig,
1818
)
1919

2020

@@ -187,9 +187,10 @@ def test_ready_initializes_driver_for_api_process(monkeypatch):
187187
_set_argv(monkeypatch, ["gunicorn"])
188188
_set_testing(monkeypatch, False)
189189

190-
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
191-
"api.attack_paths.database.init_driver"
192-
) as init_driver:
190+
with (
191+
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
192+
patch("api.attack_paths.database.init_driver") as init_driver,
193+
):
193194
config.ready()
194195

195196
init_driver.assert_called_once()
@@ -200,9 +201,10 @@ def test_ready_skips_driver_for_celery(monkeypatch):
200201
_set_argv(monkeypatch, ["celery", "-A", "api"])
201202
_set_testing(monkeypatch, False)
202203

203-
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
204-
"api.attack_paths.database.init_driver"
205-
) as init_driver:
204+
with (
205+
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
206+
patch("api.attack_paths.database.init_driver") as init_driver,
207+
):
206208
config.ready()
207209

208210
init_driver.assert_not_called()
@@ -213,9 +215,10 @@ def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
213215
_set_argv(monkeypatch, ["manage.py", "migrate"])
214216
_set_testing(monkeypatch, False)
215217

216-
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
217-
"api.attack_paths.database.init_driver"
218-
) as init_driver:
218+
with (
219+
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
220+
patch("api.attack_paths.database.init_driver") as init_driver,
221+
):
219222
config.ready()
220223

221224
init_driver.assert_not_called()
@@ -226,9 +229,10 @@ def test_ready_skips_driver_when_testing(monkeypatch):
226229
_set_argv(monkeypatch, ["gunicorn"])
227230
_set_testing(monkeypatch, True)
228231

229-
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
230-
"api.attack_paths.database.init_driver"
231-
) as init_driver:
232+
with (
233+
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
234+
patch("api.attack_paths.database.init_driver") as init_driver,
235+
):
232236
config.ready()
233237

234238
init_driver.assert_not_called()

api/src/backend/api/tests/test_models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,39 @@ def test_invalid_metadata_xml_fails(self, tenants_fixture):
243243
assert "Invalid XML" in errors["metadata_xml"][0]
244244
assert "not well-formed" in errors["metadata_xml"][0]
245245

246+
def test_xml_bomb_rejected(self, tenants_fixture):
247+
"""
248+
Regression test: a 'billion laughs' XML bomb in the SAML metadata field
249+
must be rejected and not allowed to exhaust server memory / CPU.
250+
251+
Before the fix, xml.etree.ElementTree was used directly, which does not
252+
protect against entity-expansion attacks. The fix switches to defusedxml
253+
which raises an exception for any XML containing entity definitions.
254+
"""
255+
tenant = tenants_fixture[0]
256+
xml_bomb = (
257+
"<?xml version='1.0'?>"
258+
"<!DOCTYPE bomb ["
259+
" <!ENTITY a 'aaaaaaaaaa'>"
260+
" <!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'>"
261+
" <!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'>"
262+
" <!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'>"
263+
"]>"
264+
"<md:EntityDescriptor entityID='&d;' "
265+
"xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'/>"
266+
)
267+
config = SAMLConfiguration(
268+
email_domain="xmlbomb.com",
269+
metadata_xml=xml_bomb,
270+
tenant=tenant,
271+
)
272+
273+
with pytest.raises(ValidationError) as exc_info:
274+
config._parse_metadata()
275+
276+
errors = exc_info.value.message_dict
277+
assert "metadata_xml" in errors
278+
246279
def test_metadata_missing_sso_fails(self, tenants_fixture):
247280
tenant = tenants_fixture[0]
248281
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">

api/src/backend/api/tests/test_views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15526,6 +15526,22 @@ def test_finding_groups_check_id_icontains(
1552615526
assert len(response.json()["data"]) == 1
1552715527
assert "bucket" in response.json()["data"][0]["id"].lower()
1552815528

15529+
def test_finding_groups_check_title_icontains(
15530+
self, authenticated_client, finding_groups_fixture
15531+
):
15532+
"""Test searching check titles with icontains."""
15533+
response = authenticated_client.get(
15534+
reverse("finding-group-list"),
15535+
{
15536+
"filter[inserted_at]": TODAY,
15537+
"filter[check_title.icontains]": "public access",
15538+
},
15539+
)
15540+
assert response.status_code == status.HTTP_200_OK
15541+
data = response.json()["data"]
15542+
assert len(data) == 1
15543+
assert data[0]["id"] == "s3_bucket_public_access"
15544+
1552915545
def test_resources_not_found(self, authenticated_client):
1553015546
"""Test 404 returned for nonexistent check_id."""
1553115547
response = authenticated_client.get(

0 commit comments

Comments
 (0)