Repository navigation
One line stamped in another zone no longer blinds a UTC target's log events and deadlocks (#4046) - #4049
Conversation
… read (#4046, part 1) The pg_read_file log readers (pg_log_events, pg_deadlocks) now read the target's own log_timezone in the statement that reads the tail. When that setting renders UTC, a line whose zone is not a zero-offset one cannot be the server's own, so the assembler skips it, counts it, and the read goes on; the count lands on the run's collection_log row with a note naming the setting and #4046. Any other setting, or a row without one, refuses exactly as #2993 did. The RDS log-API routes run no SQL and keep today's behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…line # Conflicts: # Darling/Darling.Tests/PgLogEventsPipelineTests.cs
Security review, round 1 — PR #4049 (#4046 part 1: foreign-zone line no longer refuses a UTC target's log read)Reviewer: Opus security lane. Scope per brief: availability only; forgery (part 2) reported only where this PR makes it worse. Verified against a real PostgreSQL 18.4 throwaway cluster ( VerdictThe zone-skip logic itself is sound and does exactly what it claims: a non-zero-offset line under a UTC HIGH — an invalid-UTF-8 byte in a role name still refuses the WHOLE read, on the exact UTC targets this PR protects, and needs no %u/%dLocation: PerformanceMonitor.Collectors/PgServerLogTail.cs:125 (the Threat. Any client that can reach the port (the PR's own adversary model) sends one failed login whose role name contains an invalid UTF-8 byte, e.g. a single 0xFF. PostgreSQL writes the role name into the FATAL message text — Repro (verified on the rig, production query shape):
Why this PR should address or scope-down. The change advertises (code + PR body) that a planted line "no longer refuses every read of the target." That is now false for the invalid-byte planted line, which is easier to plant than the newline forgery the PR does handle (no prefix requirement). This exception is also not one of the named non-fatal skips, so it surfaces as a raw collector error each cycle. Fix (SQL layer, since C# is downstream of the throw): read the tail as bytes and decode leniently. Read pg_read_binary_file(path, off, len) into a C# byte[] and decode with a replacing UTF8Encoding(false, throwOnInvalidBytes:false) (or Encoding.UTF8.GetString with the replacement fallback), so an invalid byte becomes U+FFFD in one line rather than refusing the slab. If that is intended for a later part, scope the PR's "no longer refuses every read" wording to the zone case and file/reference an issue for the residual invalid-byte hole (matching how #3009/#4019 are tracked from the type header). Answers to the six questions
Clean (checked, sound)
Static + live review; no source edited. Rig removed. |
… managed route learns log_timezone (#4046 parts 1b, 1c) Part 1c: pg_read_file() returns text, which PostgreSQL validates against the client encoding before any row reaches Darling. A failed login can plant one byte that is not valid UTF-8 (0xFF) in the FATAL role/database-name echo, and that byte throws 22021 for the whole 4 MB tail read, blinding pg_log_events, pg_deadlocks and pg_plan_capture for as long as it sits in the window. PgReadBinaryFileCapability checks has_function_privilege for pg_read_binary_file, cached per target for an hour (reset on restart), checked as its own round trip since PostgreSQL enforces a function's permission at executor init and a CASE cannot hide an ungranted call. When granted, all three collectors send PgServerLogTail.TailCteBinarySql instead of the text route: pg_log_events decodes the whole bytea body with Encoding.UTF8 (already lenient, U+FFFD on an invalid byte); pg_deadlocks and pg_plan_capture keep their server-side regexp_matches by wrapping the body in encode(..., 'escape') and reversing it client-side (PgBinaryTailText). Verified on a throwaway PostgreSQL 18.6 rig: escape() leaves every printable-ASCII byte literal, including tab and newline, and only backslash-doubles and \NNN-octal-escapes NUL and bytes >= 0x80 - so the existing patterns need no change. When the grant is missing, the text route is unchanged, and a 22021 on it now gets a named PERMISSIONS message citing the planted byte and the GRANT that fixes it, alongside the two existing pg_read_file grant messages in the same switch. Part 1b: RdsLogEventIngestor and RdsDeadlockIngestor read log_timezone over a throwaway connection to the target each cycle (a sighup setting, so no restart is needed for it to apply) and thread it into the same IsUtcLogTimezoneSetting/Classify/Extract(..., logTimezoneIsUtc, out foreignZoneLines) path #4049 gave the self-hosted route, instead of always refusing a foreign-zone line. A read failure keeps today's refusal. foreign_zone_lines_skipped is measured the same way as the self-hosted route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…parts 1b, 1c) (#4051) * Read the server log through pg_read_binary_file when granted, and the managed route learns log_timezone (#4046 parts 1b, 1c) Part 1c: pg_read_file() returns text, which PostgreSQL validates against the client encoding before any row reaches Darling. A failed login can plant one byte that is not valid UTF-8 (0xFF) in the FATAL role/database-name echo, and that byte throws 22021 for the whole 4 MB tail read, blinding pg_log_events, pg_deadlocks and pg_plan_capture for as long as it sits in the window. PgReadBinaryFileCapability checks has_function_privilege for pg_read_binary_file, cached per target for an hour (reset on restart), checked as its own round trip since PostgreSQL enforces a function's permission at executor init and a CASE cannot hide an ungranted call. When granted, all three collectors send PgServerLogTail.TailCteBinarySql instead of the text route: pg_log_events decodes the whole bytea body with Encoding.UTF8 (already lenient, U+FFFD on an invalid byte); pg_deadlocks and pg_plan_capture keep their server-side regexp_matches by wrapping the body in encode(..., 'escape') and reversing it client-side (PgBinaryTailText). Verified on a throwaway PostgreSQL 18.6 rig: escape() leaves every printable-ASCII byte literal, including tab and newline, and only backslash-doubles and \NNN-octal-escapes NUL and bytes >= 0x80 - so the existing patterns need no change. When the grant is missing, the text route is unchanged, and a 22021 on it now gets a named PERMISSIONS message citing the planted byte and the GRANT that fixes it, alongside the two existing pg_read_file grant messages in the same switch. Part 1b: RdsLogEventIngestor and RdsDeadlockIngestor read log_timezone over a throwaway connection to the target each cycle (a sighup setting, so no restart is needed for it to apply) and thread it into the same IsUtcLogTimezoneSetting/Classify/Extract(..., logTimezoneIsUtc, out foreignZoneLines) path #4049 gave the self-hosted route, instead of always refusing a foreign-zone line. A read failure keeps today's refusal. foreign_zone_lines_skipped is measured the same way as the self-hosted route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * Unit tests, a once-a-day binary-grant advisory, and doc updates for #4046 parts 1b/1c Closes the PR's own handoff list from the previous lane: unit tests for PgBinaryTailText, PgReadBinaryFileCapability, the three log-tail collectors' binary-route selection, the 22021 fault arm, and the RDS ingestors' foreign-zone handling; a new once-a-day advisory note for a self-hosted target still on the text route; and the pg_read_binary_file grant added beside every documented pg_read_file grant (README, runbook, and the 42501 fault message). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * A NUL byte in the log tail becomes U+FFFD instead of U+0000, so the store's INSERT can't refuse it (#4046) The binary route decoded a NUL byte to U+0000, which PostgreSQL refuses in a text or jsonb column: the store's INSERT would throw 22021 every cycle the byte sat in the window, the same blindness the route exists to end, moved to the store and blamed on it. Both decoders now map it to U+FFFD like any other byte that is not valid text. Also: the pg-verification-rig README names pg_read_binary_file's grant beside pg_read_file's, and the runbook says pg_read_file validates against the database encoding, not the client's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * The live grant test drops its role cleanly, the statics tests stop racing, and the runner's grant gate is tested (#4046) - IsGrantedAsync_AgainstDevPostgres failed on CI with 2BP01: a role that holds EXECUTE on pg_read_binary_file can't be dropped. It now runs DROP OWNED BY first, guarded, and uses unpooled role connections. It moves to its own class in the live-postgres collection, because it writes the shared DARLING_TEST_PG database (LivePostgresCollectionHygieneTests). - The capability and advisory test classes share process-wide statics and the same target keys, so running them in parallel let one class's Reset() or fresh cache entry fail the other's cache-hit or TTL-expiry assertion. They now share a serialized collection. - The runner's grant gate moves into ResolvePgReadBinaryFileGrantAsync with a theory over engine and collector name. Breaking the gate turned no test red before this change. - The log_timezone read sets CommandTimeout before CommandText, where the straggler guard's two-statement scan sees it. - The U+FFFD literals are the escape sequence again, not the raw character. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * The live grant test tears down through LiveStoreCleanup, and the DecoderReplacementFallback cref is bounded as outside the repository (#4046) CI's full Darling suite on 11d7e8b failed three hygiene guards this PR tripped: - LiveCleanupConversionRatchetTests: the live grant test's finally ran its role drop by hand, so a failing drop would have replaced the body's real failure. It now goes through LiveStoreCleanup.RunAsync with bodySucceeded as the try's last statement. - DocCommentHygieneTests (both): PgBinaryTailText's doc comment crefs System.Text.DecoderReplacementFallback, which no C# here spells. It is a real BCL type named to explain Encoding.UTF8's default, so it joins UnresolvedCrefTargets as OUTSIDE. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * Round-1 review of #4051: the RDS log_timezone read runs, planted-byte blinding counts as an error, and the binary route serves UTF8 databases only (#4046) - H1: the managed route's log_timezone read sent pg_catalog.current_setting('log_timezone') with no SELECT, so every call failed with 42601 and part 1b never took effect on an RDS or Aurora target. It now sends LogTimezoneReadSql ("SELECT " + the expression), a failure logs at Warning instead of Debug, and RdsLogTimezoneReadLiveTests runs the exact statement against DARLING_TEST_PG. Red-watch: without the SELECT the live test fails with 42601. - M1: a 22021 on a log-tail reader recorded PERMISSIONS, which drops out of error_count and the daily error figures, so a reader an attacker blinds most of the week could still band HEALTHY. It now keeps ERROR. The general handler records it with the same sentence, from LogTailUndecodableByteExplanation. - L3: that sentence is withheld from a proven store write (#3111's rule). - L1: a 22021, or a 42501 while the binary route is in use, drops the target's cached grant verdict, so a grant made in answer to the sentence, or a revoke, takes effect on the next cycle instead of up to an hour later. A 42501 on the text route leaves it alone, so an ungranted target is not re-probed every cycle. The runbook and README say when a grant takes effect. - L4: the probe answers NULL for a database that is not UTF8, which stays on the text route and gets no advisory. The binary route decodes as UTF-8, so it would have turned a LATIN1 database's own non-ASCII text into U+FFFD. Verified live: t on a UTF8 database, NULL on a SQL_ASCII one. - Nits: two comments said pg_read_file validates against the client encoding; it is the database encoding. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * The general fault arm composes its message from CollectorFaultCopyPhase.Describe first again, and the 22021 sentence overrides it (#4046) StoreCopyPhaseTests pins the general arm's shape in source: one "var message = CollectorFaultCopyPhase.Describe(ex);", with the ERROR log line taking that message within 400 characters. b7136a9 folded the 22021 sentence into that declaration, so CI's full run failed the pin. The declaration is back as it was; the next line swaps in LogTailUndecodableByteExplanation's sentence when there is one, and the stale-verdict call moves after the log line. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv * #4051 round-2 review: serve SQL_ASCII, say when the grant cannot help, throttle the zone-read warning - M-1: the byte-route probe now answers the grant on SQL_ASCII databases as well as UTF8. On SQL_ASCII with a UTF8 client, pg_read_file's text output is validated as UTF8 and fails 22021 on a planted byte (reproduced on PG18), and UTF-8 decoding of the bytea is lossless there. - L-1: on an encoding the byte route does not serve (cached NULL verdict), or on a 22P05, the log-tail ERROR sentence says the grant does not help and names #4062 instead of advising it. A 22021 on an unserved encoding no longer drops the verdict every cycle. - L-2: the log_timezone read failure logs Warning once an hour per server, Debug in between, and re-arms after a success. - The general handler makes one LogTailGeneralFault call (sentence first, then the stale-verdict drop), so both steps are unit-covered. A store-write 22021 keeps the verdict. - The capability cache is keyed by the runtime's StorageName through one helper. - Live test: SQL_ASCII probe against a scratch database, and a keyed Invalidate instead of the global Reset that raced the statics collection. - Docs: runbook and README say UTF8 or SQL_ASCII, and why EUC/WIN1252 are not helped by the grant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ntries in their sections (#4080) Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran. #3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased]. Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #4046.
Part 1 only (availability). Part 2 (read csvlog/jsonlog) keeps the issue open.
Why
PgLogEntryAssemblerrefused the whole read when any primary line's zone was not a zero-offset one. #4046 showed that%uand%dinlog_line_prefixare filled from the startup packet before authentication and written unescaped. So one failed login from anyone who can reach the port can leave a line stamped in another zone in the tail. From then on, everypg_log_eventsandpg_deadlocksread of that target was refused as "log_timezone is not UTC" for as long as the line stayed in the window, even on a target whoselog_timezoneis UTC.What changes
The setting decides, not the line count. Both
pg_read_filelog readers (pg_log_events,pg_deadlocks) now readpg_catalog.current_setting('log_timezone')in the same statement that reads the tail (PgServerLogTail.LogTimezoneSql, a second column; the marker arms carry NULL).If the setting renders UTC (
PgDeadlockLogParser.IsUtcLogTimezoneSetting): a line whose zone failsIsZeroOffsetLogZonecannot be the server's own. The assembler skips it, counts it, and ends the open entry, the way it handles an unrecognised line, so neither it nor the tab lines under it join a genuine entry. The read goes on.IsZeroOffsetLogZoneaccepts, so a genuine line is never the one skipped.Any other setting, or no setting (NULL), refuses exactly as PgDeadlockLogParser captures the log's timezone abbreviation and never reads it — occurred_at assumes UTC no matter what log_timezone says #2993 did. The assembler accepts nothing it didn't before.
The count is surfaced without a migration. It lands on the run's
collection_logrow two ways:foreign_zone_lines_skipped=N;log_timezone,%u/%dand A client can spell a pid and label into %u/%d and refuse or forge a target's log read, under any prefix with %u/%d before %p (the RDS default) #4046 (DarlingCollectorRunner.WithForeignZoneLinesNote, applied in the worker right after the raw_line_hash and statement_fingerprint are unkeyed hashes a store reader can use to guess the literals they hide #4004 rotation note).A count of zero records nothing, so ordinary rows keep a NULL note.
Routes that keep today's behavior, because they cannot learn the setting in their own statement:
RdsLogEventIngestorandRdsDeadlockIngestor. They get log text from the AWS API and run no SQL against the target. The managed default prefix%t:%r:%u@%d:[%p]:carries%u/%d, so the refusal half is still open on RDS/Aurora. There are two ways to close it; the coordinator needs to pick one:log_timezoneover the target's SQL connection in the RDS runner each cycle (a separate round trip, not the same statement);I did not file an issue, because A client can spell a pid and label into %u/%d and refuse or forge a target's log read, under any prefix with %u/%d before %p (the RDS default) #4046 stays open for part 2.
Plan capture was never blocked by this.
PgPlanLogParserandPgPlanCaptureCollectormatch the header without reading the zone, so they never raised the refusal, and they are unchanged. The CHANGELOG entry therefore says log events and deadlocks, not plans.Lite runs no PostgreSQL collector. The shared parser tests run in Lite.Tests.
Test plan
PgDeadlockLogParserTests:foreign_zone_lines_skipped=3);America/New_York,Europe/Londonand NULL still refuse;IsUtcLogTimezoneSetting.PgLogEventsPipelineTests:(#4046)besideforeign_zone_lines_skipped=1, and a quiet run keeps a NULL note;AgainstAUtcTarget_TheLogQueriesReadTheSettingWithTheTail_AndStoreWhatTheServerWrote, gated on the newDARLING_TEST_PG_UTC_LOG_TARGET.logging_collector = on,log_timezone = 'UTC',log_line_prefix = '%m %u@%d [%p] '.events=7 deadlocks=0 foreign_zone_lines_skipped=1, with no refusal, and the events went through COPY into the store and were read back.PgLogTimezoneUnsupportedException("timestamped 'EST' ... log_timezone is not UTC") on a target whoselog_timezoneIS UTC.PgDeadlockLogTimezoneTests;CollectorMeasurementSeamTestslabel census.git merge origin/dev, on a freshly created darlingtest: Darling 13,082 total, 0 failed, 36 skipped. Lite 5,206 total, 0 failed.For the coordinator to double-check
UTC0and always-GMT zones likeAfrica/Abidjankeep the refusal on purpose.log_timezone(for example America/New_York, then UTC) are now skipped and counted instead of refusing the read. The note says so.🤖 Generated with Claude Code
https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
Round-1 security review (Opus) and the coordinator's disposition
The full report is in the review comment.
LogTimezoneIsUtc's guards;pg_read_filereturnstext, so the whole tail read throwsinvalid byte sequencebefore any C# runs. Both collectors are blinded while the byte stays in the window.pg_read_binary_fileand a lenient client-side decode. The documented target grants coverpg_read_fileonly, so it needs a capability check and a fallback, and its own PR and review.