fix(api): gate response-example completeness and document the SAML users response - #661
Merged
Conversation
…ers response
The 20260731 campaign (542 -> 162 true positives) confirmed every 1.6.4
remediation landed -- zero 500s, and /saml/providers/{idp}/users went from
69/69 x 401 to 58 x 200 -- but schema findings came in at 72 rather than the
predicted ~4. The 4 on GET .../diagrams/{id}/model were exactly the predicted
unsatisfiable MinimalDiagramModel.metadata residual; the other 68 were the same
CATS example-vs-schema mechanism (#637, Endava/cats#206) on operations the
previous run never got a 2xx from.
Root cause of the miss: 1.6.4 built examples from observed response bodies,
which kept values honest but silently skipped any property that was null or
absent in the fixture. Absent-in-fixture is not absent-in-schema.
PATCH /teams/{team_id} produced 54 findings purely because its example lacked
email_address and uri.
Fix the class, not the instances:
- New `make check-response-examples` (wired into `make validate-openapi`) fails
when a 2xx response example omits a property its schema declares. It is a
ratchet in both directions -- a new gap fails the build, and a baselined gap
that no longer reproduces must be retired, so the file cannot silently
re-accumulate debt.
- 24 example properties added: every gap the server demonstrably returns,
verified against all 107,608 responses in the run. Values come from the
schema, never from the corpus -- an earlier pass pulled a fuzzer payload
(criticality="SONXBHYIJTGAEK...") and a real seeded user's UUID into the spec,
which is exactly the contamination 1.6.4 warned about.
- GET /saml/providers/{idp}/users gets a real response schema. It was declared
`{"type":"object"}`, so its shape was entirely undocumented and its example
even listed an `id` field the server never returns. Newly reachable since
#650, so nobody had noticed.
- SAML_USERS_CROSS_PROVIDER_403 false-positive rule. RandomResources expects 404
for a nonexistent idp; the handler answers 403 before any lookup, deliberately
refusing to distinguish a foreign provider from a nonexistent one, because 404
would make the endpoint a provider-existence oracle.
611 remaining gaps across 93 operations are baselined rather than filled. None
fires today. Some are merely unexercised, but some mean the schema declares more
than the server returns -- Project.team is declared a full Team while the server
returns {id, name}, and team.created_at appears in 0 of 107,608 responses.
Filling those would document fields the endpoint never emits, which is worse
than the gap. Tracked in #659.
Verified: validate-openapi 0 errors, generate-api (v2.7.1, embedded spec only,
no type changes), build-server, lint 0 issues, test-unit 2451 passed,
test-dev-scripts 177 passed, test-integration 82 passed, security review no
HIGH/MEDIUM findings.
Refs #637, #657, #658, #659
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…ests The markers carry the sha of 746aa58, the commit whose bodies they describe, which could only be known once that commit existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
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.
Follow-up to #654. The campaign that PR existed to justify has now run, and this closes the gap it
exposed.
The campaign: 542 → 162
Run
20260731T200650Z, 107,608 requests, 38m. Valid run — transport errors 0.07% (limit 1%),non-FP 401s 0.08% (limit 5%), all 6 seeded fixtures survived.
/saml/providers/{idp}/users)/me/groups/{id}/members)CheckDeletedResourcesNotAvailable)Every #654 remediation landed. Zero 500s across the whole run.
/saml/providers/{idp}/userswent from 69/69 × 401 to 58 × 200 — genuinely reachable for thefirst time — and immediately produced 18 new findings, all resolved here.
Why 72 and not 4
The narrow prediction held exactly: 4 findings on
GET .../diagrams/{id}/model, theunsatisfiable
MinimalDiagramModel.metadataresidual. The other 68 were the same #637 mechanism onoperations the previous run never got a 2xx from.
#654 built examples from observed response bodies — right for value accuracy, but a property
that was null or absent in the fixture never entered the example. Absent-in-fixture is not
absent-in-schema.
PATCH /teams/{team_id}produced 54 findings because its example lackedemail_addressanduri.Coverage shifts, so this recurs indefinitely unless it becomes a gate.
Changes
make check-response-examples(new, wired intomake validate-openapi) fails when a 2xxresponse example omits a property its schema declares. It is a ratchet in both directions: a new
gap fails the build, and a baselined gap that no longer reproduces must be retired, so the file
cannot quietly re-accumulate debt. Verified by deleting
email_addressfrom the teams example — theexact regression behind the 54 findings — and confirming the build fails.
24 example properties added — every gap the server demonstrably returns, cross-checked against
all 107,608 responses. Values come from the schema, never the corpus: an earlier pass pulled a
fuzzer payload (
criticality="SONXBHYIJTGAEK...") and a real seeded user's UUID into the spec,which is precisely the contamination #654 warned about. Value selection is now schema-first and
never takes a string from response data.
GET /saml/providers/{idp}/usersgets a real response schema. It was declared{"type": "object"}— shape entirely undocumented — and its example listed anidfield the serverhas never returned, while omitting
internal_uuid,idp,totalandlast_login. Shape takenfrom the handler (
api/saml_user_handlers.go:96-123), not inferred from fuzzed responses. Kills 8findings. Nobody had noticed because the endpoint returned 401 to everyone until #650.
SAML_USERS_CROSS_PROVIDER_403false-positive rule.RandomResourcesexpects 404 for anonexistent idp; the handler answers 403 before any lookup, deliberately refusing to distinguish a
foreign provider from a nonexistent one — 404 would make the endpoint a provider-existence oracle.
403 is already documented for the operation. Kills 10. Dry-run confirmed 10 matches, zero against a
5xx. At 5.8% of remaining true positives it is marginally over the skill's 5% warn threshold, but it
is pinned to an exact path, method, status and error string, and it can only ever suppress a
denial — unauthorized cross-provider access would be a 2xx, which the rule cannot match.
What is deliberately not fixed
611 gaps across 93 operations are baselined, not filled. None fires today — each was checked
against all 107k responses and never observed. They are two different bugs: some operations are
merely unexercised, but some mean the schema declares more than the server returns.
Project.teamis declared a fullTeamwhile the server returns{id, name};team.created_atappears in 0 of 107,608 responses. Filling those would document fields the endpoint never emits
— worse than the gap, and it would reverse a deliberate #654 decision. Split out as #659 with a
concrete proposal (
TeamSummary), since narrowing the schema changes generated types.RollbackResponse.restored_entityis left alone for the same reason asMinimalDiagramModel.metadata: an untyped object whose keys depend on which entity was rolled back.Enumerating every restorable entity's properties would satisfy CATS and produce a nonsense document.
Verification
make validate-openapi— 0 errorsmake generate-api(oapi-codegen v2.7.1) — embedded spec only, no type or signature changesmake build-server,make lint— 0 issuesmake test-unit— 2451 passed, 0 failedmake test-dev-scripts— 177 passed (13 new, pinning the walker)make test-integration— 82 passed, 0 failed, 9 skippedfixture strings
The new tests are not decoration. The walker's first draft seeded its cycle guard with a schema's
own
$refbefore dereferencing it, so every$ref'd schema resolved to{}and the checkreported 3 gaps instead of 651 — passing green while blind. A gate that under-reports is worse than
no gate, because the green build gets taken as proof.
Issues filed
example, notschema#657 — upstreamexample-vs-schemadefect (Response validated against the declared 'example' instead of the 'schema': valid responses reported as "Not matching response schema" Endava/cats#206)explode: true; 10 findings, 3 fromHappyPath. Also means theseverity/priority/threat_type/threat_idsfilters have neverbeen fuzzed with valid input — the same hidden coverage loss as chore(cats): anchor-path decoys erase ~97% of the anchor's own coverage (zero 2xx on PATCH/PUT for /threat_models/{id}) #651
i/o timeouton the token-blacklist check returns 500. Surfaced as a cascadingTestDiagramCRUDfailure mid-verification; re-run passed 82/82, and the server log confirmed thetransient timeout, so it is not a regression from this change
HANDOFF.mdis refreshed — it still said "DO NOW: merge the PR".Refs #637, #657, #658, #659
🤖 Generated with Claude Code
https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc