From be94b97e496eca41b471301e9232b77e58fc6ec2 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Wed, 29 Apr 2026 13:34:04 +0200 Subject: [PATCH] fix(api): redirect scan report and compliance downloads to presigned S3 URLs (#10931) Co-authored-by: Josema Camacho --- api/CHANGELOG.md | 3 +- api/src/backend/api/apps.py | 4 +- api/src/backend/api/specs/v1.yaml | 1676 ++++++++++++++++++++++- api/src/backend/api/tests/test_views.py | 87 +- api/src/backend/api/v1/views.py | 148 +- 5 files changed, 1793 insertions(+), 125 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a78da0cb8d..37c2052b25 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed - Attack Paths: AWS scans no longer fail when enabled regions cannot be retrieved, and scans stuck in `scheduled` state are now cleaned up after the stale threshold [(#10917)](https://github.com/prowler-cloud/prowler/pull/10917) +- Scan report and compliance downloads now redirect to a presigned S3 URL instead of streaming through the API worker, preventing gunicorn timeouts on large files [(#10927)](https://github.com/prowler-cloud/prowler/pull/10927) --- @@ -20,7 +21,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🔄 Changed -- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) +- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) - `aggregate_findings`, `aggregate_attack_surface`, `aggregate_scan_resource_group_summaries` and `aggregate_scan_category_summaries` now upsert via `bulk_create(update_conflicts=True, ...)` instead of the prior `ignore_conflicts=True` / plain INSERT / `already backfilled` short-circuit. Re-runs triggered by the post-mute reaggregation pipeline no longer trip the `unique_*_per_scan` constraints nor silently drop updates, and are race-safe under concurrent writers (e.g. scan completion overlapping with a fresh mute rule) [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843) - Rename the scan-category and scan-resource-group summary aggregators from `backfill_*` to `aggregate_*` [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843) diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 543c10ab88..3209f75888 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -52,7 +52,7 @@ class ApiConfig(AppConfig): "check_and_fix_socialaccount_sites_migration", ] - # Skip Neo4j initialization during tests, some Django commands, and Celery + # Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook) if getattr(settings, "TESTING", False) or ( len(sys.argv) > 1 and ( @@ -64,7 +64,7 @@ class ApiConfig(AppConfig): ) ): logger.info( - "Skipping Neo4j initialization because tests, some Django commands or Celery" + "Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)" ) else: diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 3a8d1bc8eb..40f56f6913 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -356,7 +356,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -395,7 +395,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -1267,20 +1267,50 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since 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[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -1298,6 +1328,36 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational - in: query name: filter[inserted_at] schema: @@ -1320,6 +1380,44 @@ paths: type: string format: date description: Maximum date range is 7 days. + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__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_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -1339,7 +1437,6 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -1374,6 +1471,56 @@ paths: * `vercel` - Vercel - in: query name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - 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 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] schema: type: array items: @@ -1381,12 +1528,166 @@ paths: description: Multiple values may be separated by commas. explode: false style: form - - name: filter[search] - required: false - in: query - description: A search term. + - in: query + name: filter[region] schema: type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__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[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date - name: page[number] required: false in: query @@ -1420,6 +1721,8 @@ paths: - -severity - status - -status + - muted + - -muted - impacted_providers - -impacted_providers - resources_fail @@ -1430,12 +1733,44 @@ paths: - -pass_count - fail_count - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count - muted_count - -muted_count - new_count - -new_count - changed_count - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count - first_seen_at - -first_seen_at - last_seen_at @@ -1476,20 +1811,427 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since 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[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date + description: Maximum date range is 7 days. + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date + description: Maximum date range is 7 days. + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__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_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + 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 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - 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 + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - 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 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__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[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date - in: path name: id schema: @@ -1497,6 +2239,84 @@ paths: format: uuid description: A UUID string identifying this finding group daily summary. required: true + - 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: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -1531,14 +2351,31 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since @@ -1583,20 +2420,483 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since 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[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__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_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + 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 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - 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 + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - 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 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__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[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - 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: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -1817,7 +3117,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -1856,7 +3156,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2423,7 +3723,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2462,7 +3762,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2937,7 +4237,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2976,7 +4276,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3449,7 +4749,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3488,7 +4788,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3949,7 +5249,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3988,7 +5288,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -4436,8 +5736,16 @@ paths: /api/v1/integrations/{integration_pk}/jira/dispatches: post: operationId: integrations_jira_dispatches_create - description: Send a set of filtered findings to the given integration. At least - one finding filter must be provided. + description: |- + Send a set of filtered findings to the given integration. At least one finding filter must be provided. + + ## Known Limitations + + ### Issue Types with Required Custom Fields + + Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail. + + To avoid this, select an issue type that does not require additional custom fields - **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding. summary: Send findings to a Jira integration parameters: - in: query @@ -4498,6 +5806,47 @@ paths: task_args: null metadata: null description: '' + /api/v1/integrations/{integration_pk}/jira/issue_types: + get: + operationId: integrations_jira_issue_types_retrieve + description: Fetch the available issue types from Jira for a given project key + and update the integration configuration. + summary: Get available issue types for a Jira project + parameters: + - in: query + name: fields[jira-issue-types] + schema: + type: array + items: + type: string + enum: + - project_key + - issue_types + 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: integration_pk + schema: + type: string + required: true + - in: query + name: project_key + schema: + type: string + description: The Jira project key to fetch issue types for. + required: true + tags: + - Integration + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/IntegrationJiraIssueTypesResponse' + description: '' /api/v1/integrations/{id}: get: operationId: integrations_retrieve @@ -5790,7 +7139,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -5829,7 +7178,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -5969,7 +7318,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6008,7 +7357,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6336,7 +7685,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6375,7 +7724,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6549,7 +7898,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6588,7 +7937,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7004,7 +8353,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7043,7 +8392,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7182,7 +8531,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7221,7 +8570,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7384,7 +8733,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7423,7 +8772,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8227,7 +9576,7 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8266,7 +9615,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8307,7 +9656,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8346,7 +9695,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9034,7 +10383,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9073,7 +10422,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9585,7 +10934,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9624,7 +10973,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9949,7 +11298,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9988,7 +11337,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -10319,7 +11668,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -10358,7 +11707,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -11135,6 +12484,21 @@ paths: schema: type: string format: date + - in: query + name: filter[id] + schema: + type: string + format: uuid + - in: query + name: filter[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[inserted_at] schema: @@ -11199,7 +12563,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -11238,7 +12602,7 @@ paths: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -11567,6 +12931,73 @@ paths: schema: $ref: '#/components/schemas/ScanUpdateResponse' description: '' + /api/v1/scans/{id}/cis: + get: + operationId: scans_cis_retrieve + description: Download the CIS Benchmark compliance report as a PDF file. When + a provider ships multiple CIS versions, the report is generated for the highest + available version. + summary: Retrieve CIS Benchmark compliance report + parameters: + - in: query + name: fields[scans] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - duration + - provider + - task + - inserted_at + - started_at + - completed_at + - scheduled_at + - next_scan_at + - processor + - 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 scan. + 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: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: PDF file containing the CIS compliance report + '202': + description: The task is in progress + '401': + description: API key missing or user not Authenticated + '403': + description: There is a problem with credentials + '404': + description: The scan has no CIS reports, or the CIS report generation task + has not started yet /api/v1/scans/{id}/compliance/{name}: get: operationId: scans_compliance_retrieve @@ -12381,12 +13812,53 @@ paths: 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[date_joined] + schema: + type: string + format: date + - in: query + name: filter[date_joined__date] + schema: + type: string + format: date + - in: query + name: filter[date_joined__gte] + schema: + type: string + format: date-time + - in: query + name: filter[date_joined__lte] + schema: + type: string + format: date-time + - in: query + name: filter[role] + schema: + type: string + x-spec-enum-id: 12c359ee5dc51001 + enum: + - member + - owner + description: |- + * `owner` - Owner + * `member` - Member - name: filter[search] required: false in: query description: A search term. schema: type: string + - in: query + name: filter[tenant] + schema: + type: string + format: uuid + - in: query + name: filter[user] + schema: + type: string + format: uuid - name: page[number] required: false in: query @@ -12440,9 +13912,12 @@ paths: /api/v1/tenants/{tenant_pk}/memberships/{id}: delete: operationId: tenants_memberships_destroy - description: Delete the membership details of users in a tenant. You need to - be one of the owners to delete a membership that is not yours. If you are - the last owner of a tenant, you cannot delete your own membership. + description: 'Delete a user''s membership from a tenant. This action: (1) removes + the membership, (2) revokes all refresh tokens for the expelled user, (3) + removes their role grants for this tenant, (4) cleans up orphaned roles, and + (5) deletes the user account if this was their last membership. You must be + a tenant owner to delete another user''s membership. The last owner of a tenant + cannot delete their own membership.' summary: Delete tenant memberships parameters: - in: path @@ -13265,6 +14740,11 @@ paths: schema: type: string format: uuid + - in: query + name: filter[user] + schema: + type: string + format: uuid - name: page[number] required: false in: query @@ -13460,6 +14940,7 @@ components: query: type: string minLength: 1 + maxLength: 10000 required: - query required: @@ -14329,6 +15810,8 @@ components: type: string status: type: string + muted: + type: boolean impacted_providers: type: array items: @@ -14341,12 +15824,44 @@ components: type: integer fail_count: type: integer + manual_count: + type: integer + pass_muted_count: + type: integer + fail_muted_count: + type: integer + manual_muted_count: + type: integer muted_count: type: integer new_count: type: integer changed_count: type: integer + new_fail_count: + type: integer + new_fail_muted_count: + type: integer + new_pass_count: + type: integer + new_pass_muted_count: + type: integer + new_manual_count: + type: integer + new_manual_muted_count: + type: integer + changed_fail_count: + type: integer + changed_fail_muted_count: + type: integer + changed_pass_count: + type: integer + changed_pass_muted_count: + type: integer + changed_manual_count: + type: integer + changed_manual_muted_count: + type: integer first_seen_at: type: string format: date-time @@ -14364,13 +15879,30 @@ components: - check_id - severity - status + - muted - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count FindingGroupResponse: type: object properties: @@ -14972,16 +16504,46 @@ components: type: string minLength: 1 issue_type: - enum: - - Task type: string - description: '* `Task` - Task' - x-spec-enum-id: b527b0cec62087c1 + minLength: 1 required: - project_key - issue_type required: - data + IntegrationJiraIssueTypes: + 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-issue-types + id: {} + attributes: + type: object + properties: + project_key: + type: string + readOnly: true + issue_types: + type: array + items: + type: string + readOnly: true + IntegrationJiraIssueTypesResponse: + type: object + properties: + data: + $ref: '#/components/schemas/IntegrationJiraIssueTypes' + required: + - data IntegrationResponse: type: object properties: @@ -19582,7 +21144,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 uid: type: string title: Unique identifier for the provider, set by the provider @@ -19704,7 +21266,7 @@ components: - googleworkspace - vercel type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 description: |- Type of provider to create. @@ -19776,7 +21338,7 @@ components: - googleworkspace - vercel type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 description: |- Type of provider to create. diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 736cbbd56e..97796c97ca 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3841,9 +3841,14 @@ class TestScanViewSet: "prowler-output-123_threatscore_report.pdf", ) + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/" + "tenant-id/scan-id/threatscore/prowler-output-123_threatscore_report.pdf" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) mock_s3_client = Mock() mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]} - mock_s3_client.get_object.return_value = {"Body": io.BytesIO(b"pdf-bytes")} + mock_s3_client.generate_presigned_url.return_value = presigned_url mock_env_str.return_value = bucket mock_get_s3_client.return_value = mock_s3_client @@ -3852,19 +3857,26 @@ class TestScanViewSet: url = reverse("scan-threatscore", kwargs={"pk": scan.id}) response = authenticated_client.get(url) - assert response.status_code == status.HTTP_200_OK - assert response["Content-Type"] == "application/pdf" - assert response["Content-Disposition"].endswith( - '"prowler-output-123_threatscore_report.pdf"' - ) - assert response.content == b"pdf-bytes" + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == presigned_url mock_s3_client.list_objects_v2.assert_called_once() - mock_s3_client.get_object.assert_called_once_with(Bucket=bucket, Key=pdf_key) + mock_s3_client.generate_presigned_url.assert_called_once_with( + "get_object", + Params={ + "Bucket": bucket, + "Key": pdf_key, + "ResponseContentDisposition": ( + 'attachment; filename="prowler-output-123_threatscore_report.pdf"' + ), + "ResponseContentType": "application/pdf", + }, + ExpiresIn=300, + ) def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch): """ - When output_location is an S3 URL and the S3 client returns the file successfully, - the view should return the ZIP file with HTTP 200 and proper headers. + When output_location is an S3 URL and the object exists, + the view should return a 302 redirect to a presigned S3 URL. """ scan = scans_fixture[0] bucket = "test-bucket" @@ -3878,22 +3890,33 @@ class TestScanViewSet: type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(), ) + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/report.zip" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) + class FakeS3Client: - def get_object(self, Bucket, Key): + def head_object(self, Bucket, Key): assert Bucket == bucket assert Key == key - return {"Body": io.BytesIO(b"s3 zip content")} + return {} + + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert ClientMethod == "get_object" + assert Params["Bucket"] == bucket + assert Params["Key"] == key + assert Params["ResponseContentDisposition"] == ( + 'attachment; filename="report.zip"' + ) + assert ExpiresIn == 300 + return presigned_url monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) - assert response.status_code == 200 - expected_filename = os.path.basename("report.zip") - content_disposition = response.get("Content-Disposition") - assert content_disposition.startswith('attachment; filename="') - assert f'filename="{expected_filename}"' in content_disposition - assert response.content == b"s3 zip content" + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == presigned_url def test_report_s3_success_no_local_files( self, authenticated_client, scans_fixture, monkeypatch @@ -4032,23 +4055,31 @@ class TestScanViewSet: ) match_key = "path/compliance/mitre_attack_aws.csv" + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/path/compliance/mitre_attack_aws.csv" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) class FakeS3Client: def list_objects_v2(self, Bucket, Prefix): return {"Contents": [{"Key": match_key}]} - def get_object(self, Bucket, Key): - return {"Body": io.BytesIO(b"ignored")} + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert ClientMethod == "get_object" + assert Params["Key"] == match_key + assert Params["ResponseContentDisposition"] == ( + 'attachment; filename="mitre_attack_aws.csv"' + ) + assert ExpiresIn == 300 + return presigned_url monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) framework = match_key.split("/")[-1].split(".")[0] url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework}) resp = authenticated_client.get(url) - assert resp.status_code == status.HTTP_200_OK - cd = resp["Content-Disposition"] - assert cd.startswith('attachment; filename="') - assert cd.endswith('filename="mitre_attack_aws.csv"') + assert resp.status_code == status.HTTP_302_FOUND + assert resp["Location"] == presigned_url def test_compliance_s3_not_found( self, authenticated_client, scans_fixture, monkeypatch @@ -4251,8 +4282,8 @@ class TestScanViewSet: scan.save() fake_client = MagicMock() - fake_client.get_object.side_effect = ClientError( - {"Error": {"Code": "NoSuchKey"}}, "GetObject" + fake_client.head_object.side_effect = ClientError( + {"Error": {"Code": "NoSuchKey"}}, "HeadObject" ) mock_get_s3_client.return_value = fake_client @@ -4275,8 +4306,8 @@ class TestScanViewSet: scan.save() fake_client = MagicMock() - fake_client.get_object.side_effect = ClientError( - {"Error": {"Code": "AccessDenied"}}, "GetObject" + fake_client.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied"}}, "HeadObject" ) mock_get_s3_client.return_value = fake_client diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 37ee7be341..fbb3775b40 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -53,7 +53,7 @@ from django.db.models import ( ) from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast, Coalesce, RowNumber -from django.http import HttpResponse, QueryDict +from django.http import HttpResponse, HttpResponseBase, HttpResponseRedirect, QueryDict from django.shortcuts import redirect from django.urls import reverse from django.utils.dateparse import parse_date @@ -2080,24 +2080,38 @@ class ScanViewSet(BaseRLSViewSet): }, ) - def _load_file(self, path_pattern, s3=False, bucket=None, list_objects=False): + def _load_file( + self, + path_pattern, + s3=False, + bucket=None, + list_objects=False, + content_type=None, + ): """ - Loads a binary file (e.g., ZIP or CSV) and returns its content and filename. + Resolve a report file location and return the bytes (filesystem) or a redirect (S3). Depending on the input parameters, this method supports loading: - - From S3 using a direct key. - - From S3 by listing objects under a prefix and matching suffix. - - From the local filesystem using glob pattern matching. + - From S3 using a direct key, returns a 302 to a short-lived presigned URL. + - From S3 by listing objects under a prefix and matching suffix, returns a 302 to a short-lived presigned URL. + - From the local filesystem using glob pattern matching, returns the file bytes. + + The S3 branch never streams bytes through the worker; this prevents gunicorn + worker timeouts on large reports. Args: path_pattern (str): The key or glob pattern representing the file location. s3 (bool, optional): Whether the file is stored in S3. Defaults to False. bucket (str, optional): The name of the S3 bucket, required if `s3=True`. Defaults to None. list_objects (bool, optional): If True and `s3=True`, list objects by prefix to find the file. Defaults to False. + content_type (str, optional): On the S3 branch, forwarded as `ResponseContentType` + so the presigned download advertises the same Content-Type the API used to send. + Ignored on the filesystem branch. Returns: - tuple[bytes, str]: A tuple containing the file content as bytes and the filename if successful. - Response: A DRF `Response` object with an appropriate status and error detail if an error occurs. + tuple[bytes, str]: For the filesystem branch, the file content and filename. + HttpResponseRedirect: For the S3 branch on success, a 302 redirect to a presigned `GetObject` URL. + Response: For any error path, a DRF `Response` with an appropriate status and detail. """ if s3: try: @@ -2144,25 +2158,45 @@ class ScanViewSet(BaseRLSViewSet): # path_pattern here is prefix, but in compliance we build correct suffix check before key = keys[0] else: - # path_pattern is exact key + # path_pattern is exact key; HEAD before presigning to preserve the 404 contract. key = path_pattern - try: - s3_obj = client.get_object(Bucket=bucket, Key=key) - except ClientError as e: - code = e.response.get("Error", {}).get("Code") - if code == "NoSuchKey": + try: + client.head_object(Bucket=bucket, Key=key) + except ClientError as e: + code = e.response.get("Error", {}).get("Code") + if code in ("NoSuchKey", "404"): + return Response( + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) return Response( - { - "detail": "The scan has no reports, or the report generation task has not started yet." - }, - status=status.HTTP_404_NOT_FOUND, + {"detail": "There is a problem with credentials."}, + status=status.HTTP_403_FORBIDDEN, ) - return Response( - {"detail": "There is a problem with credentials."}, - status=status.HTTP_403_FORBIDDEN, - ) - content = s3_obj["Body"].read() + filename = os.path.basename(key) + # escape quotes and strip CR/LF so a malformed key cannot break out of the header + safe_filename = ( + filename.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\r", "") + .replace("\n", "") + ) + params = { + "Bucket": bucket, + "Key": key, + "ResponseContentDisposition": f'attachment; filename="{safe_filename}"', + } + if content_type: + params["ResponseContentType"] = content_type + url = client.generate_presigned_url( + "get_object", + Params=params, + ExpiresIn=300, + ) + return HttpResponseRedirect(url) else: files = glob.glob(path_pattern) if not files: @@ -2205,12 +2239,16 @@ class ScanViewSet(BaseRLSViewSet): bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") loader = self._load_file( - key_prefix, s3=True, bucket=bucket, list_objects=False + key_prefix, + s3=True, + bucket=bucket, + list_objects=False, + content_type="application/x-zip-compressed", ) else: loader = self._load_file(scan.output_location, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2248,13 +2286,19 @@ class ScanViewSet(BaseRLSViewSet): prefix = os.path.join( os.path.dirname(key_prefix), "compliance", f"{name}.csv" ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="text/csv", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "compliance", f"*_{name}.csv") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2287,13 +2331,19 @@ class ScanViewSet(BaseRLSViewSet): "cis", "*_cis_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "cis", "*_cis_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2327,13 +2377,19 @@ class ScanViewSet(BaseRLSViewSet): "threatscore", "*_threatscore_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "threatscore", "*_threatscore_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2367,13 +2423,19 @@ class ScanViewSet(BaseRLSViewSet): "ens", "*_ens_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "ens", "*_ens_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2406,13 +2468,19 @@ class ScanViewSet(BaseRLSViewSet): "nis2", "*_nis2_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "nis2", "*_nis2_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2445,13 +2513,19 @@ class ScanViewSet(BaseRLSViewSet): "csa", "*_csa_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "csa", "*_csa_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader