Skip to content

fix(api): stop hand-listing fields in the vulnerability detail response - #413

Merged
haksungjang merged 1 commit into
mainfrom
fix-382-detail-response-model-validate
Sep 6, 2026
Merged

fix(api): stop hand-listing fields in the vulnerability detail response#413
haksungjang merged 1 commit into
mainfrom
fix-382-detail-response-model-validate

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Summary

_detail_response built VulnerabilityDetailResponse by naming all 41 fields as keyword arguments by hand. A key the service payload already carried but nobody added to that call was dropped silently, the model field has a default, so it serialized as null and a client read "the server has no value" while the database had one. This happened twice already (kev/kev_due_date during X1, then the ER28a ownership fields).

Fix

  • _detail_response now builds the response with VulnerabilityDetailResponse.model_validate(payload) instead of a hand-listed keyword call. There is no per-field call site left to fall behind the payload, and the nested structures (affected_components, status_history, upgrade_recommendation) no longer need the per-item model_validate calls the old code made by hand either, Pydantic validates a dict or list of dicts against a nested model field on its own, verified by test_full_payload_round_trips_through_model_validate.
  • VulnerabilityDetailResponse now declares model_config = ConfigDict(extra="forbid"), so a payload key the schema does not know about raises instead of being silently dropped.

Audit: how exposed is this pattern elsewhere

Counted (not estimated) with an AST scan of apps/backend/api/v1/*.py for direct instantiation of an imported schemas.* class with only keyword arguments (excluding .model_validate(...) call sites):

Metric Count Files
All hand-kwarg-listed response construction sites (>= 4 kwargs) 44 21
High-risk sites (>= 10 kwargs, comprehensive "detail" responses, same shape as this bug) 12 10

High-risk sites, for a follow-up decision:

File Class Kwargs
vulnerabilities.py VulnerabilityDetailResponse 42 (this PR)
components.py ComponentDetailResponse 28
policy_gate.py GateResultResponse 25
licenses.py LicenseDetailResponse 18
obligations.py ObligationDetailResponse 17
projects.py ProjectOverviewResponse 17
remediation.py RemediationPullRequestOut 12
api_keys.py APIKeyCreateOut 11
external_packages.py ExternalPackageLookupOut 11
remediation.py NpmDryRunResponse 11
vulnerabilities.py UpgradeCluster 11
gate_policies.py EffectiveGatePolicyOut 10

This PR only converts vulnerabilities.py's VulnerabilityDetailResponse. The other 11 high-risk sites (and the 32 lower-risk list/wrapper sites) are unaffected and are a separate follow-up, converting them touches files outside this PR's scope and each needs its own check that the service payload's dict shape actually matches the model 1:1 before flipping to model_validate + extra="forbid".

Guard

The existing AST guard (test_detail_builder_carries_every_field.py) compared the builder's keyword-argument set to the model's declared fields, that comparison has no target once there is no keyword-argument list to read. Replaced it with:

  • test_the_builder_still_uses_model_validate, AST regression guard: fails if _detail_response ever calls VulnerabilityDetailResponse(...) directly again (the exact shape of Detail response builder silently drops fields absent from its list #382), and fails if it stops calling .model_validate some other way without updating this guard.
  • test_extra_forbid_actually_rejects_an_unknown_payload_key / test_missing_required_field_actually_raises, runtime proof that extra="forbid" and required-field validation actually fire, not just that they're declared.
  • test_full_payload_round_trips_through_model_validate / test_every_declared_field_is_present_in_the_test_fixture, the fixture payload is checked against model_fields so a newly added model field can't silently stop being exercised by these tests.

I chose "redefine the guard to catch regression to hand-listing" over "extend the AST guard to also check payload-direction," because model_validate + extra="forbid" already enforces the payload direction at runtime on every request, a second static check of the same property would be redundant. What a static check still earns its keep on is the shape of _detail_response itself reverting to hand-listed kwargs, which is what the new AST test targets.

Verification

  • ruff check apps/backend, clean.
  • mypy apps/backend (whole package, not just changed files), clean, 944 source files.
  • pytest tests/unit/api/test_detail_builder_carries_every_field.py tests/unit/test_vulnerability_service.py tests/integration/test_vulnerabilities_api.py tests/integration/test_finding_assignment.py tests/integration/test_transition_approvals_api.py, 259 passed, 7 skipped.
  • Coverage of changed modules: api/v1/vulnerabilities.py 87%, schemas/vulnerability_detail.py 100% (line coverage; the misses in the API module are pre-existing, unrelated endpoints).
  • extra="forbid" proven live, not just unit-tested: temporarily added an unknown key to the service's actual return dict (_build_detail_payload), ran the real HTTP integration test (test_detail_happy_path) against it, got a 500 RFC 7807 problem response with pydantic_core.ValidationError: ... Extra inputs are not permitted, then reverted the mutation (confirmed the service file is unchanged in this PR).
  • Regression guard proven live: temporarily reverted _detail_response to VulnerabilityDetailResponse(id=payload["id"]), confirmed test_the_builder_still_uses_model_validate goes red, then restored the fix (confirmed green again).
  • OpenAPI schema diff: docs-site/static/openapi.json was already stale on main before this change (python scripts/dump_openapi.py --check fails on main HEAD too, from unrelated in-flight work). Diffing a fresh dump before/after this PR's changes shows exactly one change: VulnerabilityDetailResponse gains "additionalProperties": false plus its extended docstring, the intended effect of extra="forbid", and nothing else in the spec moved. Not regenerating the committed docs-site/static/openapi.json here since it's outside this PR's scope and already drifted for unrelated reasons.
  • node tools/em-dash/lint.mjs, clean.

Follow-ups (not in this PR)

  • Convert the other 11 high-risk (>= 10 kwargs) hand-listed response builders listed above to model_validate + extra="forbid", one file at a time.
  • The 32 lower-risk sites (list/page wrappers with items/total/cursor-style fields) carry less risk since they wrap a list rather than naming every domain field, but are the same shape and worth a lower-priority pass.

Closes #382

_detail_response built VulnerabilityDetailResponse by naming all 41
fields as keyword arguments. A field the service computed but nobody
added to that call was dropped silently and read as "no value" instead
of a server error, which happened twice already (kev/kev_due_date,
then the ownership fields).

Switch to VulnerabilityDetailResponse.model_validate(payload) so there
is no per-field call site to fall behind, and add extra="forbid" so an
unmatched payload key now raises instead of vanishing. Replace the AST
guard's now-obsolete kwarg-vs-field comparison with a regression check
for the hand-listed-kwarg shape plus a runtime proof that forbid/
required-field validation actually fires.

Closes #382
@haksungjang
haksungjang merged commit ff17f1c into main Sep 6, 2026
25 checks passed
@haksungjang
haksungjang deleted the fix-382-detail-response-model-validate branch September 6, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Detail response builder silently drops fields absent from its list

1 participant