fix(api): stop hand-listing fields in the vulnerability detail response - #413
Merged
Merged
Conversation
_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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_detail_responsebuiltVulnerabilityDetailResponseby 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 asnulland a client read "the server has no value" while the database had one. This happened twice already (kev/kev_due_dateduring X1, then the ER28a ownership fields).Fix
_detail_responsenow builds the response withVulnerabilityDetailResponse.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-itemmodel_validatecalls the old code made by hand either, Pydantic validates a dict or list of dicts against a nested model field on its own, verified bytest_full_payload_round_trips_through_model_validate.VulnerabilityDetailResponsenow declaresmodel_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/*.pyfor direct instantiation of an importedschemas.*class with only keyword arguments (excluding.model_validate(...)call sites):High-risk sites, for a follow-up decision:
vulnerabilities.pyVulnerabilityDetailResponsecomponents.pyComponentDetailResponsepolicy_gate.pyGateResultResponselicenses.pyLicenseDetailResponseobligations.pyObligationDetailResponseprojects.pyProjectOverviewResponseremediation.pyRemediationPullRequestOutapi_keys.pyAPIKeyCreateOutexternal_packages.pyExternalPackageLookupOutremediation.pyNpmDryRunResponsevulnerabilities.pyUpgradeClustergate_policies.pyEffectiveGatePolicyOutThis PR only converts
vulnerabilities.py'sVulnerabilityDetailResponse. 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 tomodel_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_responseever callsVulnerabilityDetailResponse(...)directly again (the exact shape of Detail response builder silently drops fields absent from its list #382), and fails if it stops calling.model_validatesome other way without updating this guard.test_extra_forbid_actually_rejects_an_unknown_payload_key/test_missing_required_field_actually_raises, runtime proof thatextra="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 againstmodel_fieldsso 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_responseitself 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.api/v1/vulnerabilities.py87%,schemas/vulnerability_detail.py100% (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 a500RFC 7807 problem response withpydantic_core.ValidationError: ... Extra inputs are not permitted, then reverted the mutation (confirmed the service file is unchanged in this PR)._detail_responsetoVulnerabilityDetailResponse(id=payload["id"]), confirmedtest_the_builder_still_uses_model_validategoes red, then restored the fix (confirmed green again).docs-site/static/openapi.jsonwas already stale onmainbefore this change (python scripts/dump_openapi.py --checkfails onmainHEAD too, from unrelated in-flight work). Diffing a fresh dump before/after this PR's changes shows exactly one change:VulnerabilityDetailResponsegains"additionalProperties": falseplus its extended docstring, the intended effect ofextra="forbid", and nothing else in the spec moved. Not regenerating the committeddocs-site/static/openapi.jsonhere 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)
model_validate+extra="forbid", one file at a time.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