Repository navigation
feat(#33): first-party ClickHouse LDAP compatibility profile as dormant internal/ldap/profile (phase 2) - #38
Conversation
Introduce the new internal/ldap/profile package (stdlib-only, not yet imported by cmd/ch-oauth-ldap) with the Phase 2 compatibility profile's single iterative RFC 4514-subset DN parser: ParseDN, structural DN.Equal and DN.String round-trip rendering, the Bind-shape helper ParseBindDN, the safe value escaper EscapeDNValue, and the synthetic group DN renderer RenderGroupDN. Covers p2-01-dn-grammar of the phase 2 plan (plan-33p2.md): accepted/rejected grammar forms, hostile inputs, the structural-vs-suffix equivalence table, Bind-shape cases, and two native fuzz targets (FuzzRestrictedDN, FuzzMemberAssertionDN) seeded from the plan's DN seed list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…ntrols Implements protocol.go (LDAP application tags, the Controls context tag, the plan's ten result codes, the 65536-byte body cap and 30s deadline constants, and the shared minimalPositiveInt32 validator reused later by Abandon per amendment 6) and frame.go (readFrame's bounded pre-allocation framing check, the LDAPMessage envelope decoder, and the minimal Controls scanner), plus frame_test.go's boundary/malformed-form tests and frame_fuzz_test.go's FuzzLDAPFrame seeded from the committed ClickHouse wire corpus and every named boundary vector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…tobyte+no-general-LDAP) Add internal/securitytest/profile_dependency_contract_test.go with two Phase 2 dependency contracts, reusing the existing dependency_contract_test.go helpers (resolveGoBin, deterministicGoListEnv, normalizeDepsOutput, moduleRoot) without modifying that file: - TestDependencyContract_ProfileImplementationIsNotProduction: fails if internal/ldap/profile appears in ./cmd/ch-oauth-ldap's live production closure during Phase 2; documented for Phase 4 inversion (Amendment 5). - TestDependencyContract_ProfileClosureHasRequiredPrimitiveAndNoGeneralLDAP: requires golang.org/x/crypto/cryptobyte in internal/ldap/profile's own closure and forbids the old/general LDAP stack (vjeantet/ldapserver, vjeantet/goldap by prefix, go-ldap/ldap/v3, go-asn1-ber/asn1-ber, Azure/go-ntlmssp) plus internal/wirefixture, as a required-primitive- plus-denylist policy, not a pinned "minimal" closure. Both sabotages named in the plan were performed and restored: a blank profile import added to cmd/ch-oauth-ldap/main.go broke the first test; a blank goldap import added to internal/ldap/profile/doc.go broke the second. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…gnostics Adds encode.go (BindResponse/SearchResultDone/SearchResultEntry/unsupported response encoders, a shared minimal-positive-INTEGER MessageID encoder, the closed diagnostic enum with addLDAPResultFields, and overflow-checked response-memory-bound preflight arithmetic via entryPDUSize) and encode_test.go (exact-byte fixtures built from independent BER helpers, covering every result/diagnostic pairing named by the plan, the 127/128 MessageID boundary, the cn-only entry shape, and the 65536/65537 preflight boundary), per plan-33p2.md's Response encoding/Diagnostics/Response memory bound sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…vocabulary) Byte-identical correlation_id derivation (sha256(issuer+"\x00"+subject), first 8 bytes, lowercase hex) reimplemented from internal/ldap/server.go, plus a closed `reason` enum preserving the eight legacy Bind/Search classification strings verbatim and four new profile-only rejection classes, a closed `op` name set, and the unexported log-emission helpers for every Bind/Search/unsupported/critical-control log line the plan specifies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…d canonical test fakes Adds public Config/Verifier/RoleResolver plus ValidateConfig (config.go), the connection-owned authState/connection struct with clearAuth/replaceAuth (session.go), and the canonical in-package fakeVerifier/fakeResolver/ fakeClock/marker constants (fakes_test.go, Amendment 7) that later profile-package sub-tasks reuse rather than redefine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…licy Implements p2-06-bind: bind.go's handleBind (LDAPv3 simple Bind, [3] SASL -> 7, any other authentication tag or malformed ASN.1 -> close, version != 3 -> 2, clear-before-validate, the package's one Verify/Roles call site, replace-not-merge re-Bind), bind_test.go (handler-level via net.Pipe covering the full state/result policy table, clear-before- validate, marker-leak absence, exact success log line), and bind_fuzz_test.go (FuzzBindRequest seeded from wirefixture-decoded committed Bind PDUs plus named boundary vectors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…ter, and limits Implements handleSearch: the fixed ClickHouse Search shape (subtree/deref0/ typesOnly=false/one "cn" attribute), the nonrecursive two-predicate groupOfNames/member membership filter decoder with structural member comparison, Amendment 2's ENUMERATED range rule (out-of-range scope/deref closes, in-range-but-out-of-profile returns 50), client-declared sizeLimit/timeLimit execution with fail-closed response-PDU size preflight (result 11), and the terminal-deadline invariant (every SearchResultDone uses a fresh now+writeTimeout, never an expired search deadline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…atch Adds server.go: the public Server type (New/Serve/Stop), the 256- connection admission cap with the package's sole production goroutine spawn, the per-connection read/dispatch/respond loop, and dispatch on application tag for Bind/Search/Abandon/Unbind/the six recognizable unsupported operations plus critical-control handling. Stop never holds the lifecycle mutex across a wait, so shutdown converges even with an in-flight blocking Verify or a stalled connection. Covers server_test.go (construction guards, Serve/Stop lifecycle, Accept- error policy, shortened-deadline drops, real-TCP dispatch incl. Abandon/ Unbind/unsupported-op table/Cancel narrowing) and conncap_test.go (exact 256/257 admission boundary, slot reuse, and a stalled-body load variant). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…file AST-only (go/parser, no go/types) checks over every non-test .go file directly under internal/ldap/profile: exactly one production goroutine spawn (serveConnection, inside admission), no map/channel-shaped server state beyond the active-connection map and serveDone/stopDone, no sync.Map, no legacy LDAP/BER/wirefixture/unsafe import, decodeMembershipFilter's nonrecursive two-decodeEquality-call shape, diagnostic/reason closed-vocabulary constant-only call sites (Amendment 3) with no dynamic conversion, and Verify/Roles restricted to exactly one call site each inside bind.go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Add internal/ldap/profile_compat_test.go (package ldap_test) driving both the legacy internal/ldap.Server and internal/ldap/profile.Server over real TCP with a real go-ldap/v3 client, asserting parity (not just both-pass) for every case the phase-2 plan claims: valid simple Bind, invalid credentials, SASL->7, Search-before-Bind, two-connection isolation, Alice->Bob rebind replace / failed rebind clear (TestProfileCompatibility_RebindReplaceAndClear), role snapshot cloning, zero roles, supported base/scope/filter/member Search with cn/CN projection, sizeLimit/timeLimit, unknown non-critical Controls, critical Bind/Search (result 12), identical log messages/keys, exact correlation_id VALUE equality for the same fake (issuer, subject), and credential redaction. Test fakes are duplicated per the plan's coordinator-approved Amendment 7 (canonical copy: internal/ldap/profile/fakes_test.go). Also add comment-only legacy/current-behavior disposition annotations (no code changes) above TestAdversarial_CancelExtendedOperationCannotAffectBindOrLeak and TestAdversarial_AbandonRunsPromptlyDespiteSaturatedOrdinaryWork in adversarial_test.go, and above TestControls_CriticalCancelDoesNotCancelTarget in controls_test.go, per the plan's "Disposition of named legacy test files" section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Ports internal/ldap/protocol_test.go's port/adapt rows onto internal/ldap/profile/protocol_test.go: real LDAPv3 Bind/Search black-box proofs driven over a loopback TCP listener with both the go-ldap v3 client (test-only import) and raw hand-built PDUs where exact bytes are the subject. Covers TestProfile_SimpleBindV3, TestProfile_BindVersion2ProtocolError, re-Bind replace/clear, Search- before-Bind, two-connection isolation, zero-role Search, membership authorization (wrong member DN, case/hex-escaped spelling, and a comma-and-plus-shaped username proving no server-side RFC 4515 unescaping), unsupported-operation fail-closed via go-ldap's own Add/Modify/Delete/Compare/ModifyDN/WhoAmI plus a raw Cancel Extended request, unknown-non-critical-control tolerance, critical-control handling on both Bind (clears auth) and Search (preserves it), exact supported-profile log lines, Bind-time role-snapshot semantics, TestProfile_OperationsAreSerial, and TestProfile_SearchFixedFieldPolicy's full narrowing table. Same-connection concurrency and generic attribute projection are explicitly left unported per the plan's disposition table, with a comment naming why. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…h tests Adds internal/ldap/profile/adversarial_test.go and midsearch_test.go (package profile), covering the plan's remaining adversarial proofs over real TCP: framing/body-cap boundaries with an allocation recorder, partial-header/partial-body read-deadline stalls alongside an unaffected connection, a non-reading client during a Search hitting the write deadline, malformed-BER survival (truncated envelope, MessageID 0, trailing bytes, non-canonical Controls BOOLEAN) closing only their own connection, bounded Stop under 50 stalled connections, a marker-leak scan across a JWT-shaped password/hostile DN/verifier and resolver error markers with a self-check proving the scanner's own sensitivity, and three TestProfileNarrowing_* tests characterizing (not preserving) the deliberate peer-disconnect/ordinary-Abandon/ordinary- Cancel narrowings versus legacy's vendored RouteMux. midsearch_test.go covers abrupt client close after partial entry reads, a recording net.Conn proving no further LDAPMessage is ever appended after a partial/failed entry write, and a pipelined Unbind proving it is processed only after its preceding Search fully completes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…, and redaction-proof tests Adds three TCP-level test files for sub-task p2-11: search_limits_test.go (sizeLimit/timeLimit boundaries and exact telemetry over real TCP with a tickingClock for deterministic mid-Search timeLimit expiry), hostile_dn_test.go (invalid UTF-8/NUL/malformed hex escapes/trailing backslash/OID types/+/;/# Bind DNs, a large-but-valid DN under the frame cap, and structural member-DN comparison), and redaction_boundary_test.go (the six stable TestProfileRedaction_* marker-absence proofs the redaction manifest names, each run at both Info and Trace zerolog levels against captured raw response bytes and log output). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Adds internal/ldap/profile/replay_test.go (TestProfileReplay): discovers every session under internal/ldap/testdata/clickhouse-wire via internal/wirefixture (test-only import), derives profile.Config from the fixture's own referenced ClickHouse LDAP XML, and replays each session's raw captured PDUs over a real TCP connection against a real profile.Server. Asserts BindResponse success with the identical MessageID (including the exact 02 01 7f / 02 02 00 80 encoding for the constructed MessageID-127/128 boundary session), the fixed cn-only/two-role Search shape, the timeout-Abandon fixture producing no response and no verifier/resolver calls after its Search already completed, and Unbind closing the connection. Cross-checks the discovered session count against an independent filesystem walk so a new fixture session can't be silently skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Add internal/ldap/profile to doc.go's scopeDirs, a new ldap-profile-diagnostic sink kind for its direct addLDAPResultFields call (distinct from the legacy SetDiagnosticMessage sink), and a general TestRedactionInventory_NestedLDAPPackagesAreRegistered guard over every internal/ldap/** subdirectory (mechanically verifying any future test-only-tooling allow-list entry via go list -deps). Classify and add manifest rows for all 37 discovered profile sinks, with marker proofs from p2-11's TestProfileRedaction_* suite for every credential-reachable one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
… goldap Adds internal/ldap/profile/differential_test.go (package profile, importing github.com/vjeantet/goldap/message only here): TestProfile_DifferentialDecoder compares normalized semantics between this package's own decoders and the vendored, independent goldap decoder on profile-valid inputs -- captured/constructed fixture Binds and Searches (via internal/wirefixture), MessageID 1/127/128/MaxInt32, Amendment 2's SASL [3]->result-7 parity case, description case variants, filter child-order swap, size/time limit boundaries, supported non-critical/critical Controls, Abandon (Amendment 6's shared minimalPositiveInt32 rule), and Unbind -- plus one malformed-input parity subtest (non-canonical `01 01 01` BOOLEAN, rejected by both). Records that Phase 4 must delete this file and replace Phase 1's independent goldap oracle with a bounded test-only cursor, never this production decoder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
… narrowings Adds the internal/ldap/profile repo-map row, its redaction scope/sink kind/ nested-package guard, and its architecture/dependency contracts to CLAUDE.md; a Phase 2 status note to integration/clickhouse/README.md clarifying the Docker suites still exercise the legacy runtime; §11 implementation-status, restricted-profile-acceptance, and the ten Phase-3 narrowings (the plan's eight plus Amendment 1's response-PDU-cap and UserRDNAttribute-regex two) to docs/clickhouse-ldap-wire-profile.md; and a development-status note (no cutover claim) with the four required subsections plus the two Amendment 1 narrowings to README.md and docs/ch-oauth-ldap-operator-guide.md §10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…s and descriptor folding, complete narrowing/LOC records
- internal/ldap/profile/search.go: a zero-byte entry-write timeout is now
reported as timeLimitExceeded (result 3) only when the Search deadline
was the operative, binding one (searchDeadlineBinding); a plain
write-deadline stall under the ordinary (non-Search) deadline —
including any stall while timeLimit=0 — now closes the connection
instead of being misreported as a time-limit event. Added
TestHandleSearch_WriteStallWithTimeLimitZeroClosesNotResult3 (close, no
result-3, no log) and TestHandleSearch_WriteStallWithOperativeSearchDeadlineReturnsResult3
(genuine write-timeout under a binding Search deadline still yields
result 3); the prior TimeLimit* tests continue to pass unchanged.
- internal/ldap/profile/dn.go: removed every %q/%s interpolation of
DN-derived attacker-controlled text from ParseDN's and ParseBindDN's
error paths (the "expected '='", NUL-byte, invalid-UTF-8, and leading-
RDN-attribute-mismatch errors) — they now carry only a byte offset or
fixed reason text, making "these sinks carry no input bytes" a
structural property rather than one that merely happens to hold because
every current caller discards the error. Updated the four corresponding
internal/securitytest/testdata/redaction-sites.tsv rows' fingerprints
(one sink_kind also changed fmt-errorf -> errors-new) and reclassified
their data_class to no-input-bytes.
- internal/ldap/profile/dn.go, search.go, differential_test.go,
config.go/config_test.go (comments only): added an ASCII-only
case-insensitive comparator (asciiEqualFold/asciiToLower) and replaced
every strings.EqualFold comparison against a wire-derived LDAP
attribute-type descriptor with it (DN.Equal, ParseBindDN, Search's
attribute-selection check, and decodeMembershipFilter's objectClass/
member classification, plus its differential-oracle test mirror) —
strings.EqualFold's full Unicode folding otherwise lets e.g. U+017F
("objectClaſs") match ASCII "objectClass". Added
TestDecodeMembershipFilter_UnicodeFoldDescriptorRejected proving that
descriptor is now rejected as out-of-profile. EqualFold comparisons
against purely operator-configured values (config_test.go) are
untouched.
- docs/clickhouse-ldap-wire-profile.md §11.3: added a precise note under
the Bind-version narrowing that version 0/negative/non-minimally-encoded
closes at decode time (shared minimalPositiveInt32 rule) rather than
ever reaching the version!=3 -> result 2 path, matching legacy goldap's
own 1..127 decode window.
- docs/clickhouse-ldap-wire-profile.md §11.1: replaced the "recorded in
the Phase 2 sub-task handoff" deferral with the measured number,
counting method, and disposition inline. Re-measured physical LOC
(`wc -l` over the nine plan-table files + config.go + doc.go) is 2,659
as of this sub-task (previously 2,608); the >2,500 disposition —
accepted, overshoot is the plan-table-omitted config.go/doc.go files,
well below ADR #32's ~3,500 architecture-review trigger, consolidation
folds into Phase 4 — is unchanged and is recorded in the issue #33 ship
log.
Gate green: go build/vet, go test -race ./..., go test -tags
phase5release ./internal/securitytest -count=1, go vet -tags
phase5release ./internal/securitytest, and
integration/clickhouse/tests/lib-tests.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 1Reviewed head: I reviewed the complete 47-file PR against #33 Phase 2, the current base ( P1 — Pre-auth request parsing reintroduces proportional allocation amplification
That contradicts this unit's explicit resource invariant: 64 KiB must be the bounded request body and attacker-controlled bodies must not acquire proportional duplicate copies/allocation amplification. The frame allocator test cannot detect allocations made after Action: keep profile fields as slices/spans aliasing the already-bounded frame wherever possible. For attributes, there is no reason to build a slice: stream the SEQUENCE, retain/compare only the first attribute, count/reject a second, and compare P2 — Simultaneous
|
Close two mechanical-gate gaps in internal/securitytest/profile_architecture_contract_test.go that ChatGPT review pass 1 found: invariants 4/6 (no map/channel state beyond the allow-list) only inspected struct fields and explicit ValueSpec MapType/ChanType declarations, missing an idiomatic inferred `x := make(map[...]...)`/`make(chan ...)` short variable declaration (an AssignStmt with no ValueSpec node at all); invariants 12/13 (Verify/Roles called exactly once, in bind.go) only counted selector-shaped CallExprs, missing a method-value extraction (`verify := c.verifier.Verify; verify(...)`) that produces neither a selector CallExpr nor a selector Fun on the later call. Both checks now inspect AssignStmt/SelectorExpr shapes directly, and four synthetic regression tests (parsing an in-memory source string, never the real profile package) prove each closed gap is caught and that the broadened Verify/Roles check still doesn't false-positive on this package's own unrelated `Roles` data-field accesses. Also corrects three documentation inaccuracies review pass 1 found: CLAUDE.md's repo-map overstated internal/ldap/profile's dependency closure as "stdlib plus cryptobyte only" when only its ASN.1/protocol layer is (config.go legitimately depends on internal/verification and the OAuth SDK); docs/clickhouse-ldap-wire-profile.md §11.4 had cryptobyte and vendored goldap's independent-decoder roles swapped (goldap's independentlyWellFormedBER, not cryptobyte, is the second, structurally independent decoder Phase 4 must replace); and §11.3 item 1 claimed the Bind version decode range (1..MaxInt32) matches legacy goldap's decode window (1..127) when it does not — versions >=128 decode successfully here and get a graceful protocolError response, where legacy never gets past decode. Item 1 is now marked as an additional, un-adjudicated Phase 3 delta rather than described as parity, matching how items 9-10 are already called out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 2Previous reviewed head: Reviewed head: The fix delta is one commit and changes only P1 — Pre-auth Search parsing still amplifies a bounded body into proportional duplicate allocationsStatus from pass 1: unchanged / still valid. var attributes []string
for !attrsSeq.Empty() {
var a []byte
if !attrsSeq.ReadASN1Bytes(&a, asn1.OCTET_STRING) { ... }
attributes = append(attributes, string(a))
}Issue #33 explicitly says the 16 MiB figure is only the body-buffer bound and to avoid proportional duplicate copies of attacker-controlled message bodies. A near-cap Search containing many one-byte AttributeDescriptions can carry on the order of 20k attributes; the Action: do not materialize AttributeSelection. Stream the sequence, keep/compare only the first attribute, and reject as soon as a second appears; compare the bytes to P2 — Simultaneous
|
Streams Search's AttributeSelection decode instead of materializing a full []string before authentication/authorization, bounding a near-cap 64 KiB request's allocation regardless of declared attribute count. Reorders executeSearch's per-role sizeLimit/timeLimit checks to match legacy's expiry-before-render/skip-before-size-check precedence, fixing a simultaneous-limit divergence at the N+1 boundary. Closes several false-negative gaps in the profile architecture contract's AST detectors (inferred `var x = make(map/chan ...)`, map composite literals, named map/chan type aliases, and a Verify/Roles receiver alias). Adds a test proving SASL Bind's result-7 precedence holds at non-3 protocol versions too, and narrows §11.3's version-check wording in docs/clickhouse-ldap-wire-profile.md to the simple-Bind path it actually covers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 3Previous reviewed head: Reviewed head: I compared the new head against pass 2, reassessed every prior finding, and reused the earlier full-PR review for the 42 files unchanged by this one-commit delta. The exact-head P2 — The architecture contract is still bypassable despite the new sabotage casesThe pass-3 change closes several concrete syntax holes, but the claimed mechanical no-map/no-channel and Verify/Roles uniqueness guarantees are still not actually exhaustive.
The Verify/Roles fix is similarly one alias deep. Action: simplify the map/channel proof by scanning every production P2 — The size/time runtime fix landed, but the promised two-backend regression case did not
However Action: add a shared old/new regression case for the simultaneous-limit precedence, or explicitly document why this edge cannot be made deterministic in the real-TCP harness and keep a separate legacy/profile deterministic proof that compares the same transition. P3 — The new allocation regression test is not actually an admitted near-cap request
This does not invalidate the streaming implementation itself, but it weakens the resource-bound regression proof. Action: construct the largest full SearchRequest that remains <=64 KiB, preferably drive it through the real frame/read path, and assert both admission and flat allocation growth. Earlier findings reassessed
VERDICT: REVISE |
Closes two remaining bypasses in the profile-package AST architecture contract (internal/securitytest/profile_architecture_contract_test.go), both flagged again in ChatGPT PR #38's pass-3 review after two prior passes closed shallower variants of the same bug classes: - findMapTypedSites/findChanTypedSites: a `var`-keyword map composite literal (`var pending = map[int32]*request{}`) and a function's declared result type (`func newActive() map[net.Conn]struct{} { ... }`, and the FuncLit/chan counterparts) were both invisible to every existing case, none of which inspected FuncType.Results or a `var` ValueSpec's CompositeLit initializer. - fieldAliasIdents: rewritten as a fixed-point transitive closure over every alias assignment (closing a second-level receiver alias like `v := c.verifier; w := v; w.Verify(...)`) and seeded with every function parameter typed as the field's own named interface type (closing an interface-typed-parameter path like `func helper(v Verifier) { v.Verify(...) }` called via `helper(o.verifier)`), instead of only recognizing one direct `<expr>.fieldName` hop. Six new regression tests prove each closed gap is caught while the full existing suite (including the pass-1/pass-2 regression tests) stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
…tead of AST shape enumeration Three consecutive external review passes each found a new ordinary-Go bypass of the AST-only architecture checker's map/channel and Verify/Roles invariants (make() forms; composite literals and method values; var-composite-literals, function result types, transitive aliases), and the post-cap architecture consultation produced counterexamples no shape enumerator covers: maps.Clone(s.active), time.After(...), a call-expression receiver, and generic instantiation at a map type. Per that verdict, invariants 4/6/12/13 are now enforced type-aware over the fully type-checked package instead of by node-shape enumeration. Mechanism (stdlib-only, no go.mod change): a single deterministic `go list -mod=readonly -deps -export -json` invocation (resolveGoBinPath + deterministicGoListEnv, shared with dependency_contract_test.go) records each dependency's gc export-data file; the profile package's non-test files are parsed into one FileSet and type-checked with go/types using importer.ForCompiler(fset, "gc", lookup), where lookup opens the recorded export file per import path. New profile_types_contract_test.go: - invariants 4/6: every object (Defs/Implicits — vars, fields, signatures, named types) and every value expression (Types) whose type is or structurally carries a map/channel is a site; allowlist is exactly Server.active/serveDone/stopDone, direct references to them, and their initializer expressions. Named types are flagged when their own underlying type is a map/channel and are otherwise opaque. - invariants 12/13: every types.Info.Selections entry (calls, method values, method expressions, any receiver expression) selecting a Verify/Roles method whose receiver implements the package's Verifier/RoleResolver interface; FieldVal selections excluded by kind. Exactly one each, in bind.go. Every historical bypass regression test is adapted to run the SAME type-aware checker functions on typecheckable synthetic packages, and the consultation's new counterexamples are covered: make() in := and var forms, map composite literals in := and var forms, named and transitive-alias map/chan types, function/closure result types, map-typed parameters, method-value extraction, one-hop and transitive receiver aliases, interface-typed parameters, maps.Clone of the allowed map, time.After's channel, a call-expression receiver, a method expression, and a generic identity instantiation — plus negative controls (allowlisted usage yields zero sites; struct-field Roles accesses are not method references). profile_architecture_contract_test.go keeps only the genuinely syntactic invariants (1-3, 5, 7-11) and its header now states the split and the new enforcement's claim boundary; CLAUDE.md and docs/clickhouse-ldap-wire-profile.md are reconciled to the same claim. Sabotage-verified in a throwaway tree copy: injected `pending := maps.Clone(s.active)`, `time.After(time.Second)`, and a receiver-alias Verify method value were each flagged with file:line and the offending type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 1Reviewed head: Base: I reviewed the complete current 49-file PR, reusing the earlier full parser/runtime review where the tree is provably unchanged. The re-certification delta from Major — constrained type parameters still bypass the new map/channel type-semantic checkerRisk score: 82/100
That makes this valid ordinary Go invisible to func hiddenMap[T ~map[int]int]() any { return make(T) }
func hiddenChan[T ~chan int]() any { return make(T) }
func bad() (any, any) {
return hiddenMap[map[int]int](), hiddenChan[chan int]()
}Why it passes the current checker:
I reproduced the current Action: make Minor — the canonical PR description is stale about the enforcement mechanismThe Bind section of the current PR description still says Verify/Roles uniqueness is “enforced by an AST architecture contract.” At this head those type-semantic invariants have intentionally moved to I found no new parser/runtime regression in this head: the runtime is byte-identical to the previously reviewed VERDICT: REVISE |
typeCarries in the go/types-based profile invariant checker did not
traverse *types.TypeParam constraints: a constrained generic function
such as `func hiddenMap[T ~map[int]int]() any { return make(T) }` (and
the analogous ~chan case) escaped detection entirely, since make(T)
types as the bare TypeParam, whose constraint's map/chan-ness lives
only in its interface's embedded *types.Union terms — never in its
method set, which is all the *types.Interface case walked. Since a
TypeParam's Underlying() already resolves to that constraint
interface, extending the existing *types.Interface case to also walk
NumEmbeddeds/EmbeddedType, plus a new *types.Union case recursing into
each term's Type(), closes the gap without a dedicated TypeParam case.
Added regression tests reproducing the reported hiddenMap/hiddenChan
bypass through collectTypedSites; verified they fail (0 sites) against
the pre-fix code and pass against the fix.
The PR description staleness finding (Bind section still describing
Verify/Roles uniqueness as AST-enforced instead of go/types-enforced)
is out of scope for this local commit and needs a `gh pr edit` outside
this change's mutation boundary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 2Previously reviewed head: Reviewed head: Base: The new head is exactly one commit ahead of the previous review and changes only P2 — The type-aware map/channel checker still misses instantiated named generic compositesThe pass-1 constrained-type-parameter hole is fixed: A distinct ordinary-generics bypass remains. type Box[T any] struct { V T }
var hiddenMap Box[map[int]int]
var hiddenChan Box[chan int]At the declaration of I reproduced the current This contradicts the file's precise claim that there is no map- or channel-carrying declaration/value expression outside the allowlist “for every way such a type ... can appear to the Go type checker.” The current production package does not contain such a wrapper; this is an acceptance/drift-gate defect, not a runtime parser bug. Action: before treating a non-map/non-channel P3 —
|
- typeCarries now recurses into a *types.Named generic instantiation's TypeArgs() before treating it as opaque, so a type like Box[map[int]int] or Box[chan int] (map/channel appearing only via a type argument, never at the named type's own declaration) is correctly detected as carrying map/channel state. Adds regression tests (TestProfileArchitecture_DetectsGenericInstantiationMapTypeArg / ...ChanTypeArg) through the real collectTypedSites, verified to fail without the fix. - Updates the stale bind.go comment describing the Verify uniqueness invariant's enforcement mechanism as "an AST-based architecture contract" to correctly name the go/types-based type-aware contract in profile_types_contract_test.go that now enforces it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
ChatGPT review pass 3Previously reviewed head: Reviewed head: Base: I re-reviewed the current 49-file PR using the prior full-PR analysis, then inspected the exact one-commit delta from Reassessment of pass-2 findings
P2 — Verify/Roles checker counts method references, not unique invocation sites
A type-safe counterexample that still passes the current invariant is: type connection struct {
verifier Verifier
verify func(string) error
}
// bind.go
func (c *connection) handleBind() {
c.verify = c.verifier.Verify // the one and only Verify selection, in bind.go
}
// unsupported.go
func (c *connection) handleUnsupported() error {
return c.verify("attacker") // invokes Verify, but creates no Verify selection
}I reproduced this with the same Action: make invariants 12/13 require the sole Verify/Roles selection to be a direct call expression in P3 — file-level contract prose is stale after the TypeArgs fixThe header of No new parser/runtime regression was found; all earlier runtime fixes remain unaffected because this head changes no runtime logic. VERDICT: REVISE |
Invariants 12/13's Verify/Roles selection checker treated a method call, a stored method value, and a method expression as equally legitimate evidence of "one site" — so replacing bind.go's direct call with a stored method value and invoking it from an unrelated handler through a plain func-field call (untracked, since it's a types.FieldVal selection) still reported exactly one selection, in bind.go, hiding the real invocation entirely. methodSelectionSites now also records whether each selection is a direct call (types.MethodVal immediately used as a CallExpr's Fun), and the invariant check rejects a sole site that isn't, with a regression test reproducing the exact counterexample and a sabotage run confirming the check catches it. Also folds the generic-instantiation TypeArgs() carve-out into the file-level "otherwise opaque" doc comment, which had drifted from the already-updated typeCarries function comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS
Part of #33 (execution tracker for ADR #32, Accepted) — Phase 2 of 4: implement the compatibility profile.
This PR adds the first-party ClickHouse-LDAP compatibility implementation as a new, dormant package
internal/ldap/profile(~2.6k physical LOC production (2,659 measured), 34+ files with tests). Production is untouched:cmd/ch-oauth-ldap, the legacy non-testinternal/ldappackage files,third_party/**andgo.mod/go.sumare byte-identical tomain, and a committed dependency-contract test fails CI if the new package (or module-path cryptobyte) ever appears in the production closure before Phase 4's cutover. Rollback: revert the merge; runtime behavior is identical either way.What the package implements (per the wire evidence from Phase 1)
frame.go): outer0x30, definite minimal lengths only (≤3 length octets), 64 KiB body cap checked before the singleallocBodycall (test hook proves zero allocation on rejection, incl. the historical ~2 GiB declaration30 84 7f ff ff ff); envelope requires a minimal positive MessageID (1..MaxInt32, shared validator reused by Abandon's[APPLICATION 16] IMPLICITtarget), exactly one protocolOp, no trailing bytes; minimal Controls scanner returns onlyhasCritical. Malformed → close, no resync.bind.go):clearAuth()before any validation on every recognizable Bind (failed re-Bind clears, success replaces — wholesale, roles cloned); empty DN/password → 49; version ≠ 3 → 2 (version 0/negative closes at decode, recorded as a narrowing); SASL[3]→ 7; any other auth tag → close. The password exists as one bounded copy passed to the Verifier and is never stored or logged. Verify/Roles are call-site-unique tobind.go— enforced type-aware byinternal/securitytest/profile_types_contract_test.go(go/typesSelectionsover the fully type-checked package, stdlib export-data importer; rewritten from the original AST shape checks after review found syntax-enumeration bypasses), so unsupported operations structurally cannot reach OAuth verification.search.go): structuralAND(objectClass=groupOfNames, member=<current BoundDN>), either child order, duplicates/extras/nesting rejected; base/scope/deref/typesOnly per the captured profile (out-of-range ENUMERATED closes; in-range out-of-profile → 50); fail-closed size limit — at most N eligible entries thensizeLimitExceeded, exact boundary tested; member DN compared structurally (never suffix/text).dn.go): iterative RFC 4514 subset (single-attribute RDNs, simple + 2-hex escapes, UTF-8 validated, NUL rejected); Bind shape exactly<rdnAttr>=<user>,<UserBaseDN>; safe syntheticcn=<escaped role>,<GroupBaseDN>rendering. Parse errors carry no input bytes (offsets/lengths only).encode.go): BindResponse, SearchResultEntry (overflow-checked size preflight), SearchResultDone, minimal error result; MessageID round-trips exactly (127 →02 01 7f, 128 →02 02 00 80, asserted at byte level and replayed over TCP).server.go): accept loop → one goroutine per admitted connection (cap 256; the 257th gets neither allocation nor goroutine), 30 s read/write deadlines, Abandon recognized/dropped with no response, Unbind/close, graceful shutdown with goroutine-settling tests.logging.go): closed 12-literalreasonvocabulary (8 legacy literals byte-identical to the old package), constant-onlydiagnostic, correlation-ID hash byte-identical across backends — all AST-enforced.Testing
profile_compat_test.godrives the legacyinternal/ldap.Serverand the newprofile.Serverover real loopback TCP with the same fakes and inputs.internal/ldap/testdata/clickhouse-wire/**session (24.8/25.8 × success/timeout-abandon + constructed 127/128) replayed over real TCP, with an independent filesystem walk so a skipped session can't hide.FuzzLDAPFrame,FuzzBindRequest,FuzzSearchRequest,FuzzRestrictedDN,FuzzMemberAssertionDN— seeds from the committed corpus, MessageID 127/128, the historical fork vulnerability shapes (2 GiB length declaration, INTEGER sign bug, filter-nesting allocation), truncated/oversized lengths, hostile DN escapes. 20 s smoke per target run clean by the coordinator.profile_architecture_contract_test.go: single production goroutine statement, nonrecursive filter, constant-only reason/diagnostic, no legacy-LDAP imports) and type-semantic invariants by go/types (profile_types_contract_test.go: only-mapServer.active, only-channelsserveDone/stopDone, Verify/Roles exactly once in bind.go — incl. generic/alias/call-receiver forms), redaction inventory extended (internal/ldap/profileis an audited scopeDir; credential-reachable sinks carry marker proofs; nested-package guard).Verification
Required PR gatecommands green locally plusgo vet -tags phase5release ./internal/securitytest;go mod tidyis a no-op.go list -deps ./cmd/ch-oauth-ldapcontains neither the new package nor module-path cryptobyte).config.go/doc.go; disposition recorded in the ship log — well below ADR ADR: Own the ClickHouse LDAP compatibility profile and remove general LDAP dependencies #32's ~3,500 architecture-review trigger; consolidation review folds into Phase 4's deletion).Deferred (later phases, recorded in the ship log)
Phase 3: gates/matrix/HA on the replacement, LOC guardrail measurement as an official gate. Phase 4: cutover (
cmd/ch-oauth-ldapswitches toprofile), deletion ofthird_party/**forks and productiongo-ldap, inversion of the dependency contracts, replacement of the test-only goldap oracles, deletion of the differential harness. The ten client-visible narrowings are recorded indocs/clickhouse-ldap-wire-profile.md§11.3 for Phase 3 acceptance.🤖 Generated with Claude Code
https://claude.ai/code/session_011KxntXZk6BFoz3uT3wFtiS