diff --git a/docs/content/en/api/metrics-endpoint.md b/docs/content/en/api/metrics-endpoint.md new file mode 100644 index 00000000000..ccf5e5d2134 --- /dev/null +++ b/docs/content/en/api/metrics-endpoint.md @@ -0,0 +1,169 @@ +--- +title: "Simple Metrics API Endpoint" +description: "API endpoint for retrieving finding metrics by product type with severity breakdown" +draft: false +weight: 3 +--- + +## Simple Metrics API Endpoint + +The Simple Metrics API endpoint provides finding counts by product type, broken down by severity levels and month status. This endpoint replicates the data from the UI's `/metrics/simple` page in JSON format, making it easier to integrate with other tools and dashboards. + +### Endpoint Details + +**URL:** `/api/v2/metrics/simple` + +**Method:** `GET` + +**Authentication:** Required (Token authentication) + +**Authorization:** User must have `Product_Type_View` permission for the product types + +### Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `date` | String (YYYY-MM-DD) | No | Date to filter metrics by month/year (defaults to current month) | +| `product_type_id` | Integer | No | Optional product type ID to filter metrics. If not provided, returns all accessible product types | + +### Response Format + +The endpoint returns an array of objects, each representing metrics for a product type: + +```json +[ + { + "product_type_id": 1, + "product_type_name": "Web Application", + "Total": 150, + "critical": 5, + "high": 25, + "medium": 75, + "low": 40, + "info": 5, + "Opened": 10, + "Closed": 8 + }, + { + "product_type_id": 2, + "product_type_name": "Mobile Application", + "Total": 89, + "critical": 2, + "high": 15, + "medium": 45, + "low": 25, + "info": 2, + "Opened": 7, + "Closed": 5 + } +] +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `product_type_id` | Integer | Unique identifier for the product type | +| `product_type_name` | String | Name of the product type | +| `Total` | Integer | Total number of findings for the product type in the specified month | +| `critical` | Integer | Number of Critical severity findings | +| `high` | Integer | Number of High severity findings | +| `medium` | Integer | Number of Medium severity findings | +| `low` | Integer | Number of Low severity findings | +| `info` | Integer | Number of Info severity findings | +| `Opened` | Integer | Number of findings opened in the specified month | +| `Closed` | Integer | Number of findings closed in the specified month | + +### Example Usage + +#### Get current month metrics +```bash +GET /api/v2/metrics/simple +``` + +#### Get metrics for January 2024 +```bash +GET /api/v2/metrics/simple?date=2024-01-15 +``` + +#### Get metrics for a specific product type +```bash +GET /api/v2/metrics/simple?product_type_id=1 +``` + +#### Get metrics for a specific product type and date +```bash +GET /api/v2/metrics/simple?date=2024-05-01&product_type_id=2 +``` + +### Error Responses + +#### 400 Bad Request - Invalid date characters +```json +{ + "error": "Invalid date format. Only numbers and hyphens allowed." +} +``` + +#### 400 Bad Request - Invalid date format +```json +{ + "error": "Invalid date format. Use YYYY-MM-DD format." +} +``` + +#### 400 Bad Request - Date out of range +```json +{ + "error": "Date must be between 2000-01-01 and one year from now." +} +``` + +#### 400 Bad Request - Invalid product_type_id format +```json +{ + "error": "Invalid product_type_id format." +} +``` + +#### 404 Not Found - Product type not found or access denied +```json +{ + "error": "Product type not found or access denied." +} +``` + +#### 403 Unauthorized - Missing or invalid authentication +```json +{ + "detail": "Authentication credentials were not provided." +} +``` + +#### 403 Forbidden - Insufficient permissions +```json +{ + "detail": "You do not have permission to perform this action." +} +``` + +### Notes + +- **Authorization Model**: This endpoint uses the same authorization model as the UI's `/metrics/simple` page, ensuring consistent access control +- **Performance**: The endpoint is optimized with database aggregation instead of Python loops for better performance +- **Date Handling**: If no date is provided, the current month is used by default +- **Timezone**: All dates are handled in the server's configured timezone +- **Product Type Access**: Users will only see metrics for product types they have permission to view +- **Data Consistency**: The data returned by this API endpoint matches exactly what is displayed on the `/metrics/simple` UI page +- **Field Naming**: The API uses descriptive field names (`critical`, `high`, `medium`, `low`, `info` for severity levels and `Total`, `Opened`, `Closed` for counts) to maintain consistency and readability +- **URL Format**: The endpoint automatically redirects requests without trailing slash to include one (301 redirect) +- **Date Validation**: The API performs two levels of date validation: first checking for valid characters (only numbers and hyphens allowed), then validating the YYYY-MM-DD format + +### Use Cases + +This endpoint is useful for: +- **Dashboard Integration**: Integrating DefectDojo metrics into external dashboards and reporting tools +- **Automated Reporting**: Creating automated reports showing security metrics trends over time +- **CI/CD Integration**: Monitoring security metrics as part of continuous integration pipelines +- **Executive Reporting**: Generating high-level security metrics for management reporting +- **Data Analysis**: Performing custom analysis on security finding trends and patterns diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index e53b0b35475..add227d1a24 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -3103,3 +3103,22 @@ class NotificationWebhooksSerializer(serializers.ModelSerializer): class Meta: model = Notification_Webhooks fields = "__all__" + + +class SimpleMetricsSerializer(serializers.Serializer): + + """Serializer for simple metrics data grouped by product type.""" + + product_type_id = serializers.IntegerField(read_only=True) + product_type_name = serializers.CharField(read_only=True) + Total = serializers.IntegerField(read_only=True) + + # Severity labels + critical = serializers.IntegerField(read_only=True) + high = serializers.IntegerField(read_only=True) + medium = serializers.IntegerField(read_only=True) + low = serializers.IntegerField(read_only=True) + info = serializers.IntegerField(read_only=True) + + Opened = serializers.IntegerField(read_only=True) + Closed = serializers.IntegerField(read_only=True) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 28c59befe08..5c55c45e3fb 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -3174,6 +3174,123 @@ def get_queryset(self): return Answered_Survey.objects.all().order_by("id") +# Authorization: authenticated +class SimpleMetricsViewSet( + viewsets.ReadOnlyModelViewSet, +): + + """ + Simple metrics API endpoint that provides finding counts by product type + broken down by severity and month status. + """ + + serializer_class = serializers.SimpleMetricsSerializer + queryset = Product_Type.objects.none() + permission_classes = (IsAuthenticated,) + pagination_class = None + + @extend_schema( + parameters=[ + OpenApiParameter( + "date", + OpenApiTypes.DATE, + OpenApiParameter.QUERY, + required=False, + description="Date to generate metrics for (YYYY-MM-DD format). Defaults to current month.", + ), + OpenApiParameter( + "product_type_id", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + required=False, + description="Optional product type ID to filter metrics. If not provided, returns all accessible product types.", + ), + ], + responses={status.HTTP_200_OK: serializers.SimpleMetricsSerializer(many=True)}, + ) + def list(self, request): + """Retrieve simple metrics data for the requested month grouped by product type.""" + from dojo.metrics.views import get_simple_metrics_data + + # Parse the date parameter, default to current month + now = timezone.now() + date_param = request.query_params.get("date") + product_type_id = request.query_params.get("product_type_id") + + if date_param: + # Input validation + if len(date_param) > 20: + return Response( + {"error": "Invalid date parameter length."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Sanitize input - only allow alphanumeric characters and hyphens + import re + if not re.match(r"^[0-9\-]+$", date_param): + return Response( + {"error": "Invalid date format. Only numbers and hyphens allowed."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + # Parse date string with validation + parsed_date = datetime.strptime(date_param, "%Y-%m-%d") + + # Date range validation + min_date = datetime(2000, 1, 1) + max_date = datetime.now() + relativedelta(years=1) + + if parsed_date < min_date or parsed_date > max_date: + return Response( + {"error": "Date must be between 2000-01-01 and one year from now."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Make it timezone aware + now = timezone.make_aware(parsed_date) if timezone.is_naive(parsed_date) else parsed_date + + except ValueError: + return Response( + {"error": "Invalid date format. Use YYYY-MM-DD format."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Optional filtering by specific product type with validation + parsed_product_type_id = None + if product_type_id: + try: + parsed_product_type_id = int(product_type_id) + except ValueError: + return Response( + {"error": "Invalid product_type_id format."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get metrics data + try: + metrics_data = get_simple_metrics_data( + now, + parsed_product_type_id, + ) + except Exception as e: + logger.error(f"Error retrieving metrics: {e}") + return Response( + {"error": "Unable to retrieve metrics data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Check if product type was requested but not found + if parsed_product_type_id and not metrics_data: + return Response( + {"error": "Product type not found or access denied."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = self.serializer_class(metrics_data, many=True) + return Response(serializer.data) + + # Authorization: configuration class AnnouncementViewSet( DojoModelViewSet, diff --git a/dojo/fixtures/unit_limit_reqresp.json b/dojo/fixtures/unit_limit_reqresp.json index 360156f533b..f8e4fa50dab 100644 --- a/dojo/fixtures/unit_limit_reqresp.json +++ b/dojo/fixtures/unit_limit_reqresp.json @@ -165,52 +165,6 @@ "hash_code": "c89d25e445b088ba339908f68e15e3177b78d22f3039d1bfea51c4be251bf4e0", "last_reviewed": null } - },{ - "pk": 8, - "model": "dojo.finding", - "fields": { - "last_reviewed_by": null, - "reviewers": [], - "static_finding": false, - "date": "2017-12-31", - "references": "", - "files": [], - "payload": null, - "under_defect_review": false, - "impact": "High", - "false_p": false, - "verified": false, - "severity": "High", - "title": "DUMMY FINDING WITH REQRESP", - "param": null, - "created": "2017-12-01T00:00:00Z", - "duplicate": false, - "mitigation": "MITIGATION", - "found_by": [ - 1 - ], - "numerical_severity": "S0", - "test": 5, - "out_of_scope": false, - "cwe": 1, - "file_path": "", - "duplicate_finding": null, - "description": "TEST finding", - "mitigated_by": null, - "reporter": 2, - "mitigated": null, - "active": false, - "line": 100, - "under_review": false, - "defect_review_requested_by": 2, - "review_requested_by": 2, - "thread_id": 1, - "url": "http://www.example.com", - "notes": [], - "dynamic_finding": false, - "hash_code": "c89d25e445b088ba339908f68e15e3177b78d22f3039d1bfea51c4be251bf4e0", - "last_reviewed": null - } },{ "pk": 123, "model": "dojo.burprawrequestresponse", diff --git a/dojo/metrics/views.py b/dojo/metrics/views.py index f56430a84c2..078a7a823dd 100644 --- a/dojo/metrics/views.py +++ b/dojo/metrics/views.py @@ -158,6 +158,92 @@ def metrics(request, mtype): """ +def get_simple_metrics_data(date=None, product_type_id=None): + """ + Get simple metrics data with optimized database queries. + + Args: + date: Optional date for the metrics (defaults to current month) + product_type_id: Optional product type ID to filter by + + Returns: + List of dictionaries containing metrics data by product type + + """ + # Use current date if not provided + if date is None: + date = timezone.now() + + # Get authorized product types + product_types = get_authorized_product_types(Permissions.Product_Type_View) + + # Optional filtering by specific product type + if product_type_id: + product_types = product_types.filter(id=product_type_id) + + # Build base filter conditions + base_filter = Q( + false_p=False, + duplicate=False, + out_of_scope=False, + date__month=date.month, + date__year=date.year, + ) + + # Apply verification status filtering if enabled + if (get_system_setting("enforce_verified_status", True) or + get_system_setting("enforce_verified_status_metrics", True)): + base_filter &= Q(verified=True) + + # Collect metrics data + metrics_data = [] + + for pt in product_types: + # Single aggregated query + metrics = Finding.objects.filter( + test__engagement__product__prod_type=pt, + ).filter(base_filter).aggregate( + # Total count + total=Count("id"), + + # Count by severity using conditional aggregation + critical=Count("id", filter=Q(severity="Critical")), + high=Count("id", filter=Q(severity="High")), + medium=Count("id", filter=Q(severity="Medium")), + low=Count("id", filter=Q(severity="Low")), + info=Count("id", filter=~Q(severity__in=["Critical", "High", "Medium", "Low"])), + + # Count opened in current month + opened=Count("id", filter=Q(date__year=date.year, date__month=date.month)), + + # Count closed in current month + closed=Count("id", filter=Q( + mitigated__isnull=False, + mitigated__year=date.year, + mitigated__month=date.month, + )), + ) + + # Build the findings summary + findings_summary = { + "product_type": pt, # Keep product type object for template compatibility + "product_type_id": pt.id, + "product_type_name": pt.name, + "Total": metrics["total"] or 0, + "critical": metrics["critical"] or 0, + "high": metrics["high"] or 0, + "medium": metrics["medium"] or 0, + "low": metrics["low"] or 0, + "info": metrics["info"] or 0, + "Opened": metrics["opened"] or 0, + "Closed": metrics["closed"] or 0, + } + + metrics_data.append(findings_summary) + + return metrics_data + + @cache_page(60 * 5) # cache for 5 minutes @vary_on_cookie def simple_metrics(request): @@ -172,71 +258,13 @@ def simple_metrics(request): else: form = SimpleMetricsForm({"date": now}) - findings_by_product_type = collections.OrderedDict() + # Get metrics data + metrics_data = get_simple_metrics_data(now) - # for each product type find each product with open findings and - # count the S0, S1, S2 and S3 - # legacy code calls has 'prod_type' as 'related_name' for product.... so weird looking prefetch - product_types = get_authorized_product_types(Permissions.Product_Type_View) - product_types = product_types.prefetch_related("prod_type") - for pt in product_types: - total_critical = [] - total_high = [] - total_medium = [] - total_low = [] - total_info = [] - total_opened = [] - findings_broken_out = {} - - total = Finding.objects.filter(test__engagement__product__prod_type=pt, - false_p=False, - duplicate=False, - out_of_scope=False, - date__month=now.month, - date__year=now.year, - ) - - closed = Finding.objects.filter(test__engagement__product__prod_type=pt, - false_p=False, - duplicate=False, - out_of_scope=False, - mitigated__month=now.month, - mitigated__year=now.year, - ) - - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - total = total.filter(verified=True) - closed = closed.filter(verified=True) - - total = total.distinct() - closed = closed.distinct() - - for f in total: - if f.severity == "Critical": - total_critical.append(f) - elif f.severity == "High": - total_high.append(f) - elif f.severity == "Medium": - total_medium.append(f) - elif f.severity == "Low": - total_low.append(f) - else: - total_info.append(f) - - if f.date.year == now.year and f.date.month == now.month: - total_opened.append(f) - - findings_broken_out["Total"] = len(total) - findings_broken_out["S0"] = len(total_critical) - findings_broken_out["S1"] = len(total_high) - findings_broken_out["S2"] = len(total_medium) - findings_broken_out["S3"] = len(total_low) - findings_broken_out["S4"] = len(total_info) - - findings_broken_out["Opened"] = len(total_opened) - findings_broken_out["Closed"] = len(closed) - - findings_by_product_type[pt] = findings_broken_out + # Convert to expected format for template + findings_by_product_type = collections.OrderedDict() + for metrics in metrics_data: + findings_by_product_type[metrics["product_type"]] = metrics add_breadcrumb(title=page_name, top_level=True, request=request) @@ -407,16 +435,16 @@ def product_type_counts(request): top_ten = severity_count(top_ten, "annotate", "engagement__test__finding__severity").order_by("-critical", "-high", "-medium", "-low")[:10] - cip = {"S0": 0, - "S1": 0, - "S2": 0, - "S3": 0, + cip = {"critical": 0, + "high": 0, + "medium": 0, + "low": 0, "Total": total_closed_in_period} - aip = {"S0": 0, - "S1": 0, - "S2": 0, - "S3": 0, + aip = {"critical": 0, + "high": 0, + "medium": 0, + "low": 0, "Total": total_overall_in_pt} for o in closed_in_period: @@ -612,16 +640,16 @@ def product_tag_counts(request): top_ten = severity_count(top_ten, "annotate", "engagement__test__finding__severity").order_by("-critical", "-high", "-medium", "-low")[:10] - cip = {"S0": 0, - "S1": 0, - "S2": 0, - "S3": 0, + cip = {"critical": 0, + "high": 0, + "medium": 0, + "low": 0, "Total": total_closed_in_period} - aip = {"S0": 0, - "S1": 0, - "S2": 0, - "S3": 0, + aip = {"critical": 0, + "high": 0, + "medium": 0, + "low": 0, "Total": total_overall_in_pt} for o in closed_in_period: @@ -898,20 +926,20 @@ def view_engineer(request, eid): more_nine += 1 # Data for the monthly charts - chart_data = [["Date", "S0", "S1", "S2", "S3", "Total"]] + chart_data = [["Date", "critical", "high", "medium", "low", "Total"]] for thing in o_stuff: chart_data.insert(1, thing) - a_chart_data = [["Date", "S0", "S1", "S2", "S3", "Total"]] + a_chart_data = [["Date", "critical", "high", "medium", "low", "Total"]] for thing in a_stuff: a_chart_data.insert(1, thing) # Data for the weekly charts - week_chart_data = [["Date", "S0", "S1", "S2", "S3", "Total"]] + week_chart_data = [["Date", "critical", "high", "medium", "low", "Total"]] for thing in week_o_stuff: week_chart_data.insert(1, thing) - week_a_chart_data = [["Date", "S0", "S1", "S2", "S3", "Total"]] + week_a_chart_data = [["Date", "critical", "high", "medium", "low", "Total"]] for thing in week_a_stuff: week_a_chart_data.insert(1, thing) diff --git a/dojo/templates/dojo/pt_counts.html b/dojo/templates/dojo/pt_counts.html index 4a418e8543b..22082a118ef 100644 --- a/dojo/templates/dojo/pt_counts.html +++ b/dojo/templates/dojo/pt_counts.html @@ -43,16 +43,16 @@