fix: CATS run-validity fixes, two API contract bugs, and a Unicode validation bypass - #635
Merged
Conversation
tmi-clients regenerated its Go clients to 1.3.0/1.6.0 and removed the v1_5_0 directory this module's `replace` pointed at, so `go build ./cmd/dbtool` failed with "replacement directory ... does not exist". That broke `make cats-seed` and therefore every CATS campaign. v1_6_0 is a drop-in: only the import path changes, no call sites move, and the version now matches the server it is generated from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
test/integration is its own module and `replace`s the parent to ../.., so the root module's dependency bumps raise its minimum versions too. Its go.mod had drifted behind, and Go refused to run the suite at all: "updates to go.mod needed; to update it: go mod tidy". The failure was quiet in the worst way. `make test-integration` still reported "22 passed, 0 failed" because the api/ package ran fine; the workflows package -- 60 tests across workflows, tier2_features and tier3_edge_cases -- never compiled. Only the skip-is-a-failure gate added in 41abfe5 turned that into a non-zero exit. Versions only, no code changes. With this the full suite runs: 82 passed, 0 failed, 9 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
The assertion compared the server's modified_at against the test
runner's own clock with no tolerance:
pm.expect(new Date(responseData.modified_at)).to.be.at.most(new Date())
That holds only while the server shares a clock with the runner. Against
CLUSTER=k3s the server is a different host, and the check failed
deterministically by ~2ms (…27.065Z vs …27.063Z) on every run -- normal
NTP drift, not a server fault.
Allow 5s of skew. Still catches a timestamp that is genuinely wrong
(unset, epoch, or far-future) while surviving a remote server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…ating them Run 20260730T153736Z destroyed 15 of its own seeded fixtures and the #625 integrity gate correctly marked it INVALID. Two of the losses were leaves with no decoy at all: an ordinary DELETE on /teams/{team_id}/notes/{team_note_id} and /admin/webhooks/subscriptions/{webhook_id} consumed the only seeded record, and every test nested under them then ran against a 404. Decoys existed only for anchors, on the theory that leaves are not fuzzed destructively. They are. Seed a throwaway note and webhook, and point each leaf path at them. The note hangs off the REAL team on purpose: only the /teams/{team_id} anchor is overridden, so the nested path still substitutes the real team_id and a note under the throwaway team would just 404. Both globals had to be pinned by name as well. teamNoteID and webhookID resolved via findRefByKind, which ranges over a map -- with a second record seeded it could return either one, and returning the decoy would leave the real fixture unfuzzed while the decoy absorbed both roles. This is the hazard the survey-response:0 lookup already guards against. Adds teamNoteRef/teamNotePrefix so the ref prefix and the lookup cannot drift; transformTeams now builds its prefix from the same helper. TestFixtureManifestMarksDecoyedAnchors asserted these two were undecoyed, which is exactly what this inverts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
Drives GET/PUT/DELETE on /admin/settings/{key} against a running server,
authenticating through a browser PKCE flow. Interactive auth is required
by design: service-account tokens are denied on /admin/* (#399).
Secrets go in via --value-file rather than argv, and the tool never
echoes a value back after a write.
Note what it cannot do. Settings supplied through the environment or a
config file outrank the database, and the server returns 409 for a PUT
to any such key. On Kubernetes deployments that inject config via
envFrom that includes every OAuth provider key, so this is not a way to
rotate provider credentials -- see #631. The 409 handler says so
explicitly rather than leaving the caller to guess.
Not yet exercised end to end; see the follow-up issue for reworking it
onto the generated Python client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…s return
CATS run 20260730T174620Z flagged 63 undocumented-response-code true
positives across three operations. In every case the handler is right
and the spec was incomplete, so document the behaviour rather than
change it (per the documented-status-code policy).
POST /admin/webhooks/subscriptions/{webhook_id}/test -> 202 (39 hits)
webhook_handlers.go:358 returns StatusAccepted: the delivery is
created and queued, and its outcome is reported separately via
the deliveries endpoints. 202 is the honest code for that.
PUT /threat_models/{threat_model_id}/assets/bulk -> 200 (23)
PUT /threat_models/{threat_model_id}/repositories/bulk -> 200 (1)
Both bulk upsert handlers return StatusOK unconditionally
(asset_sub_resource_handlers.go:647,
repository_sub_resource_handlers.go:640). Only 201 was documented.
201 is left in place on the bulk PUTs. Those handlers never actually
emit it, so it looks like dead documentation, but removing a documented
code is a contract change for clients rather than a spec correction --
worth a separate look.
Version 1.6.0 -> 1.6.1 (manual bump, #627).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…itives SSRFInUrlFields sets the uri field to an internal address and flags the value coming back in the response body. For teams and projects that echo is not evidence of a request the server made: the field is persisted and serialized (team_store_gorm.go:954) and nothing dereferences it. Scoped deliberately to the four team/project contract paths. URIs are genuinely fetched elsewhere -- Timmy pulls document and diagram content over HTTP (timmy_content_provider_http.go) through SafeHTTPClient and URIValidator -- and an SSRF finding there would be real, so the rule must not reach those paths. Suppresses 172 findings, 5.46% of the run's true positives. That is above the 5% review threshold, confirmed with the maintainer before adding; the share reflects how many tests the fuzzer aimed at these four paths, not a loose condition. Verified against run 20260730T174620Z: dry run and live reclassification both moved exactly 172 (3152 -> 2980 true positives), and the rule matched no 5xx. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
AcceptHeaderValidation kept a coarse server-wide allowlist that included four representations only two operations actually produce: text/csv and application/x-ndjson (system-audit export), application/yaml and application/graphml+xml (diagram model). The comment acknowledged the gate was blunt and assumed per-endpoint negotiation would make the precise choice -- but endpoints that do no negotiation of their own never make that choice. They passed the gate and answered 200 with JSON to a client that asked for CSV. RFC 9110 calls for 406 there, and the spec documents 406 on those operations, so this was a contract violation as well as a correctness one. CATS run 20260730T174620Z hit it 93 times, all with `Accept: text/csv`, across 83 paths that have nothing to do with CSV. Scope each type to the routes that produce it. The two handlers that call negotiateContentType still return 406 themselves for a type they do not offer, so this gate only has to stop the type being waved through everywhere else. Verified by hand before the change: application/xml, text/plain, text/html, image/png and nonsense/type were already 406, while text/csv passed the gate alongside application/json -- which is what singled out the negotiated types as the cause. Four existing subtests asserted the old server-wide behaviour on an arbitrary route. They now assert 406 there, which is the behaviour change stated as a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
UnicodeNormalizationMiddleware scanned the request body as raw text. In raw text a \uXXXX escape is six ASCII bytes, so every check it performs -- dangerous zero-width, BiDi overrides, Hangul fillers, fullwidth structural characters, control characters, excessive combining marks -- could be evaded simply by escaping the character instead of emitting it literally. Confirmed against a running server. POST /teams with a literal U+200B in the name is rejected 400 by this middleware. The byte-identical character sent as returns 201, and reading the team back shows the zero-width space stored in the database. Re-run the same checks on the decoded strings, walking object keys as well as values. Keys matter independently: a zero-width character inside a field name makes the field unrecognized, so it is silently dropped and the request still succeeds -- which is how CATS's ZeroWidthCharsInNamesFields got 96 2xx responses in run 20260730T174620Z by sending "responsible_parties". The raw-text scan stays: it catches literal characters, and it still runs first. The new pass only adds rejections. A body that does not parse is left to ordinary request validation rather than being rejected here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
Ten rules covering findings from run 20260730T174620Z where the server did the right thing and CATS expected otherwise. Security-flavoured findings, all disproved by evidence rather than assumption: AUTH_ERROR_CODE_IS_NOT_A_DETAILS_LEAK (39) -- the "leaked" keyword is "unauthorized", the RFC 6749 error code, in the standard 401 body. SSTI_PAYLOAD_REFLECTED_NOT_EVALUATED (33) -- 0 of 33 responses contained 8051, the evaluated form of the 83*97 payload; all 33 contained the payload verbatim. CATS's "engine" keyword matched our own seeded member role "engineering_lead". Requiring the unevaluated payload keeps a real evaluation surfacing. SSRF_URI_VALIDATION_REJECTS_UNRESOLVABLE_400 (117) -- this is the SSRF control reporting itself. ssrf_validator.go resolves the host and checks every IP against the blocklist, and safe_http_client.go dials the validated IP instead of re-resolving, so the lookup cannot be raced by a DNS rebind. Correct rejections of mangled payloads (600 + 46 + 18) and genuine state conflicts (230 + 87 + 15): nested schema violations, undecodable base64, corrupted pagination cursors, duplicate metadata keys, restoring a non-deleted entity, and a second threat model from one survey response. Each is keyed on the error the server actually returned rather than on the fuzzer name, so an unrelated 400/409 on the same path still surfaces. Together with the MassAssignment rule this takes the run from 2,980 true positives to 1,134. Nothing matched a 5xx. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…positives
POST /threat_models/{threat_model_id}/chat/sessions streams
text/event-stream, the only content type its 200 response declares. CATS
wraps the non-JSON body in a notAJson envelope and then reports that it
"does not match the corresponding schema" -- there is no
application/json schema for it to match, which is correct and
intentional.
Found while diagnosing the "Not matching response schema" bucket: this
was the one operation of 23 with no application/json schema declared,
and its own evidence settles it (response_content_type is
text/event-stream, exactly as documented).
12 findings. The remaining 409 in that bucket do not reproduce as schema
violations and are deliberately left unsuppressed pending a fresh run --
see the analysis notes on the branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
…t of argv
Security review of the new settings CLI found three problems in its
browser login. All are in code added earlier on this branch.
State was effectively optional. The check read
if result.get("state") not in (None, state):
so a callback carrying no state at all satisfied it -- the parameter was
only validated when the sender chose to supply it. Worse, the branch
below returned any access_token found in the callback without the state
having constrained anything.
Together that meant a page open in the operator's browser could issue a
cross-origin GET to the listener and hand the tool an identity of its
choosing for the rest of the run. The ephemeral port raises the cost of
finding the listener but does not prevent it, and the window stays open
for the full five-minute timeout.
Now:
- The handler compares state with secrets.compare_digest before it
looks at anything else, so a callback that is not bound to this flow
is 400'd and never even captured. A missing state fails exactly like
a wrong one.
- authenticate() re-checks rather than trusting a check made far away
in the file.
- A token delivered straight to the callback is refused. It is not
bound to the PKCE verifier this process generated, so accepting it
would defeat the point of using PKCE. Only a token the server mints
in exchange for our code and verifier is used.
- The listener binds 127.0.0.1 explicitly instead of the "localhost"
name.
Verified by driving the handler directly: forged callbacks with no
state, with a token and no state, and with a wrong state all return 400
and capture nothing; the correctly-stated callback returns 200 and is
captured.
Separately, --token is replaced by --token-file. A bearer token in argv
is readable by any local process via ps and lands in shell history,
which is the same reason setting values already use --value-file.
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.
Work from a full
/testsuite run: getting the suite green, getting CATS toproduce a valid campaign, and then triaging what that campaign found.
Test-suite repairs
Three separate things were broken, each masking the next.
fix(deps)— tmi-clients regenerated its Go clients and deleted thev1_5_0directory this module'sreplacepointed at, socmd/dbtoolwouldnot build and every CATS campaign failed at seeding. Repointed at
v1_6_0(drop-in; no call sites move).
fix(test)—test/integration'sgo.modhad drifted behind the rootmodule it
replaces, and Go refused to run the suite. This was quiet in theworst way:
make test-integrationreported "22 passed, 0 failed" while theentire workflows package — 60 tests — never compiled. With it fixed: 82
passed, 0 failed.
test(api)— the Postmanmodified_atassertion compared the server'sclock to the runner's with zero tolerance, so it failed deterministically by
~2ms against a remote
CLUSTER=k3sserver. Allows 5s of skew.CATS run validity
fix(dbtool)— run20260730T153736Zdestroyed 15 of its own seeded fixturesand the #625 gate correctly marked it INVALID. Two losses were leaves with no
decoy: an ordinary DELETE consumed the only seeded team note and webhook. Adds
decoys for both, and pins the corresponding globals by name — they resolved
via
findRefByKind, which ranges over a map, so a second seeded record made thelookup nondeterministic.
Fixture destruction went 15 → 1 → 0 across three campaigns.
API bugs found by the campaign
fix(api): negotiated media types —text/csv,application/x-ndjson,application/yamlandapplication/graphml+xmlsat in a server-wide Acceptallowlist though only two operations produce them. Endpoints doing no
negotiation of their own passed the gate and answered 200 with JSON to a
client asking for CSV. RFC 9110 and the spec both call for 406. 93 findings
across 83 unrelated paths.
fix(api): Unicode validation bypass —UnicodeNormalizationMiddlewarescanned the raw pre-parse body, where
\uXXXXis six ASCII bytes. Everycheck it performs was evadable by escaping the character. Confirmed live: a
literal U+200B in a team name is rejected 400, the escaped form returns 201
and the zero-width space is stored in the database. Now re-checks decoded
strings, walking keys as well as values.
fix(api): status codes — 63 undocumented-response-code findings wherethe handler was right and the spec incomplete (202 on the webhook test, 200 on
two bulk PUTs). Version 1.6.0 → 1.6.1.
Triage
test(cats)— 12 false-positive rules taking the run from 2,980 truepositives to 1,122, each keyed on the error the server actually returned
rather than on a fuzzer name, so unrelated failures on the same paths still
surface. Every security-flavoured suppression is backed by positive evidence,
not assumption:
8051(the evaluated83*97); all 33held the payload verbatim. CATS's
enginekeyword matched our own seeded roleengineering_lead.unauthorized— the RFC 6749error code in the standard 401 body.
uriis stored and echoed, never dereferenced. Scoped tothose four paths only; Timmy's content-fetch paths, which do dereference
URIs, stay in scope.
fields are dropped rather than bound. Carries a forward-looking
/admin/exclusion.
Settings CLI
feat(scripts)adds a system-settings CLI, andfix(scripts)fixes what asecurity review of it found: the OAuth callback treated
stateas optional andwould accept a token handed straight to the callback, so a page in the
operator's browser could hand the tool an identity of its choosing. Now
state-bound with
compare_digestbefore anything else is read, PKCE-boundtokens only,
127.0.0.1explicitly, and--token-fileinstead of a token inargv.
Verification
make lint0 issues ·make build-server1.6.1 ·make test-unit2447 passed,0 failed ·
make test-integration82 passed, 0 failed ·make test-api327assertions, 0 failed · CATS
20260730T174620Zvalid (0 fixtures destroyed,transport errors 0.07%, non-FP 401s 0.14%).
Not included
findings remain.
violations under a format-checking,
$ref-resolving validator and aredeliberately left unsuppressed.
/triage/and/intake/survey_responsesreference the identical schema yet only one is flagged, so the trigger is
not the schema. Wants a fresh run to re-derive.
not against the cluster.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc