Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,29 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

### Fixed

- **The vulnerability drawer's response builder could drop a field silently.**
`_detail_response` named all 41 fields of `VulnerabilityDetailResponse` as
keyword arguments by hand, so a field the service already computed but
nobody added to that call was simply missing from the response: the model
field has a default, so it serialized as `null` and read as "the server has
no value" while the database had one. This happened twice (`kev` /
`kev_due_date`, then the ownership fields added for ticket tracking), and
each time the fix was to add the missing keyword rather than remove the
possibility of forgetting one.

The builder now calls `VulnerabilityDetailResponse.model_validate(payload)`
against the service's payload dict instead, so there is no per-field call
site left to fall behind. The response model also declares
`extra="forbid"`, so a payload key the schema does not know about now raises
instead of being dropped, and a required field the payload fails to supply
raises too.

The same hand-listed-keyword-argument shape appears at roughly forty other
call sites across the API layer, a dozen of them building comprehensive
detail responses with ten or more fields the same way this one did. Only
the vulnerability drawer is changed here; the rest are unaffected and
tracked separately.

- **The nightly vendored-spec run failed on a column the product had
intentionally dropped.** The plaintext-to-ciphertext migration for webhook
secrets (0084-0086) removed `projects.webhook_secret` once its encrypted
Expand Down
84 changes: 18 additions & 66 deletions apps/backend/api/v1/vulnerabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,16 @@
from core.ratelimit import _authenticated_user_key, limiter
from core.security import CurrentUser, require_role
from schemas.vulnerability_detail import (
AffectedComponent,
UpgradeCluster,
UpgradeClusterFinding,
UpgradeClusterListResponse,
UpgradeRecommendation,
VulnerabilityAssignmentUpdate,
VulnerabilityBulkStatusResponse,
VulnerabilityBulkStatusResult,
VulnerabilityBulkStatusUpdate,
VulnerabilityDetailResponse,
VulnerabilityListItem,
VulnerabilityListResponse,
VulnerabilityStatusHistoryEntry,
VulnerabilityStatusUpdate,
)
from services.project_service import ProjectError
Expand Down Expand Up @@ -411,69 +408,24 @@ async def list_upgrade_clusters_endpoint(


def _detail_response(payload: dict[str, Any]) -> Response:
"""Shared serializer for the two endpoints that return a detail payload."""
body = VulnerabilityDetailResponse(
id=payload["id"],
project_id=payload["project_id"],
scan_id=payload["scan_id"],
cve_id=payload["cve_id"],
severity=payload["severity"],
cvss_score=payload["cvss_score"],
epss_score=payload["epss_score"],
epss_percentile=payload["epss_percentile"],
# Defect fix (found during X1): the detail payload has always carried
# kev / kev_due_date but this builder dropped them, so the drawer's
# KEV badge silently read the schema defaults (false / null).
kev=payload["kev"],
kev_due_date=payload["kev_due_date"],
cvss_vector=payload["cvss_vector"],
summary=payload["summary"],
details=payload["details"],
references=payload["references"],
matching_provenance=payload["matching_provenance"],
published_at=payload["published_at"],
status=payload["status"],
analysis_state=payload["analysis_state"],
analysis_justification=payload["analysis_justification"],
analysis_source=payload["analysis_source"],
vex_origin=payload["vex_origin"],
analyst_user_id=payload["analyst_user_id"],
analyzed_at=payload["analyzed_at"],
reachable=payload["reachable"],
reachability_source=payload["reachability_source"],
reachability_analyzed_at=payload["reachability_analyzed_at"],
affected_components=[
AffectedComponent.model_validate(c) for c in payload["affected_components"]
],
status_history=[
VulnerabilityStatusHistoryEntry.model_validate(h) for h in payload["status_history"]
],
upgrade_recommendation=(
UpgradeRecommendation.model_validate(payload["upgrade_recommendation"])
if payload.get("upgrade_recommendation") is not None
else None
),
# X1 SLA — project-level first detection + due date / status (both SLA
# fields are None for severities with no window).
first_detected_at=payload["first_detected_at"],
sla_due_date=payload["sla_due_date"],
sla_status=payload["sla_status"],
# ER28a: ownership, deadline and ticket. Listed explicitly because
# this builder names every field, which is the same reason kev /
# kev_due_date went missing above: a payload key nobody adds here is
# dropped silently and reads as "the server does not have it".
# `test_the_detail_builder_drops_nothing` fails when that happens again.
due_on=payload["due_on"],
effective_due_date=payload["effective_due_date"],
due_source=payload["due_source"],
manual_due_ignored=payload["manual_due_ignored"],
assignee_user_id=payload["assignee_user_id"],
assignee_is_active=payload["assignee_is_active"],
ticket_url=payload["ticket_url"],
ticket_key=payload["ticket_key"],
created_at=payload["created_at"],
updated_at=payload["updated_at"],
)
"""Shared serializer for the two endpoints that return a detail payload.

Issue #382: this used to name all 41 fields of ``VulnerabilityDetailResponse``
as keyword arguments by hand. A key the service payload already carried
but nobody added to that call was dropped silently (it happened twice:
kev / kev_due_date, then the ER28a ownership fields). ``model_validate``
removes the hand-listing entirely, so there is no per-field call site left
to fall behind the payload. The nested structures (affected_components,
status_history, upgrade_recommendation) do not need the per-item
``model_validate`` calls the old code had either: Pydantic validates a
dict or a list of dicts against a nested model field on its own.

The response model declares ``extra="forbid"``, so a payload key this
schema does not know about raises (500) instead of being dropped, and a
field the payload fails to supply raises too, because most fields here
have no default.
"""
body = VulnerabilityDetailResponse.model_validate(payload)
return Response(
content=body.model_dump_json(),
status_code=status.HTTP_200_OK,
Expand Down
15 changes: 14 additions & 1 deletion apps/backend/schemas/vulnerability_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,20 @@ class MatchingProvenance(BaseModel):


class VulnerabilityDetailResponse(BaseModel):
"""Full drawer payload for a single vulnerability_findings row."""
"""Full drawer payload for a single vulnerability_findings row.

``extra="forbid"``: the API builds this response with
``model_validate(payload)`` against the service's payload dict rather than
naming each field by hand (issue #382: a hand-maintained kwarg list
dropped fields the service already computed). ``forbid`` is the half of
that fix a plain ``model_validate`` would not give on its own: without it,
a payload key this model does not declare is silently discarded instead of
surfacing the drift as a 500. A field the payload FAILS to include is
still caught here too, because most fields have no default, so Pydantic
raises rather than filling in ``null``.
"""

model_config = ConfigDict(extra="forbid")

id: uuid.UUID
project_id: uuid.UUID
Expand Down
Loading
Loading