Repository navigation
get_plan_force_actions read no longer serves pre-#4326 exception text (#4346) - #4363
Merged
Merged
Conversation
) Rows written before #4326 put ex.Message straight into collect.plan_force_actions.detail for a state_unavailable blocker. Those rows stay until retention (up to a year). Nothing reads the column back today, but the read behind get_plan_force_actions (GetRecentActionsAsync) would serve it verbatim once something calls it. GetRecentActionsAsync now runs every row's detail through SanitizeDetailForAudit before returning it. There is no build marker on the row, so this is a positive allow-list: the only state_unavailable evidence line #4326 can produce (a type name, plus SQLSTATE for a DbException, plus one of two fixed log notes) passes through; any state_unavailable line that does not match that shape (a pre-#4326 row's raw exception message) is replaced with a fixed sentence naming no exception text. Every other blocker's evidence line has never carried exception text and passes through untouched. Pinned with unit tests over SanitizeDetailForAudit directly: the three post-fix shapes pass through unchanged, a legacy row carrying a credential and a hostname in its exception text is redacted to the fixed sentence, an unrelated blocker line on the same row survives, and null/non-state_unavailable detail are untouched.
…r GetPendingReviewsAsync (#4346) The prior sanitizer matched one \n-delimited line at a time and applied only to GetRecentActionsAsync. Two gaps: a pre-#4326 row's raw exception message can itself embed a newline, in which case only the first line was checked against the safe shape and everything after the embedded newline passed through untouched; and GetPendingReviewsAsync, the journal's other row reader, ran no sanitizer at all, so a caller added to that read later would serve the same unredacted exception text. SanitizeDetailForAudit now matches the whole state_unavailable BLOCK - from its prefix up to the next known blocker's line or the end of the string - and anchors the WHOLE block against the safe shape, not one line of it. GetPendingReviewsAsync passes every row's detail through the same sanitizer before returning it, at the same choke point as GetRecentActionsAsync, so neither read can ever serve detail around it. New pins: a legacy line that starts with the safe prefix and carries exception text after it; the safe shape followed by a newline (then CRLF) and exception text; a CRLF-terminated sibling blocker line ahead of a safe line, which must still pass; and three characters .NET's regex line anchors do not treat as line boundaries - a lone CR, and the Unicode LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) - none of which may let smuggled text escape the redacted block. All existing pins (the three post-fix shapes, the original legacy pin, the sibling line, non-state_unavailable detail, null) keep passing.
…audit sanitizer (#4363) F1 (HIGH): StateUnavailableBlock's sibling-prefix lookahead let a legacy exception message ending with \napc_owns_it: (or any other sibling's exact prefix) end the redacted block early, passing everything after it through verbatim. ForcePlanBotPolicy.Blockers always adds state_unavailable LAST (apc_owns_it/apc_enabled_for_database both require a non-null, non-empty state and cannot co-occur with the null/empty-state condition that adds state_unavailable — confirmed in FactRemediation.ForcePlanBlockers and ForcePlanBotPolicy.Blockers), so the block can be anchored to \z instead of a lookahead. SiblingBlockerLine_SurvivesEvenWithAdjacentRedaction pinned the writer's real order backwards; replaced with LegacyLine_EmbeddingASiblingPrefix_IsRedactedToTheEnd, which pins the actual order (sibling before state_unavailable) and asserts the impersonating text is redacted to the end of the string. F2 (MEDIUM): SafeStateUnavailableLine only recognized the #4326 exception read-failure shape, so the null-state "returned no row" and empty-state "ran and observed nothing" shapes (which never carried exception text, and the empty-state shape is the common production case) were rewritten to a false "read failed" sentence. Widened to a three-branch regex covering all three shapes ForcePlanBotPolicy.Blockers can write. New pins (NoRowShape_FromTheWriter_PassesThroughUnchanged, EmptyStateShape_FromTheWriter_PassesThroughUnchanged) build their input from ForcePlanBotPolicy.Blockers/Evidence directly rather than copied literals, so wording drift in the writer turns them red. Added BothReads_ApplyTheSameSanitizer as the missing pin that both GetRecentActionsAsync and GetPendingReviewsAsync apply the sanitizer. Verified with a throwaway net10.0 console linking a verbatim copy of both regex versions: the F1 bypass input leaks fake secrets on the old regex and is fully redacted on the new one; both F2 shapes are over-redacted on the old regex and pass through unchanged on the new one.
erikdarlingdata
marked this pull request as ready for review
September 26, 2026 01:30
This was referenced Sep 26, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Sep 26, 2026
…unrelated CandidateSql fields (#4346/#4376) - PlanForceActionDetailScrub's raw-detail SQL field is renamed from the bare CandidateSql to LegacyDetailCandidateSql. PgSettingScrub.cs and QueryStoreBackfill.cs each declare their own unrelated CandidateSql field, so a guard on the bare name failed on the real tree before the census even ran; the new name cannot collide with either. - PlanForceActionAuditRedactionTests' exemption list, its round-trip pin, and its synthetic positive/negative controls are updated to the new field name. - Fixed the scrub's remarks: a failed batch STOPS the run rather than continuing with the next batch, matching the actual break in RunAsync. - Fixed the scrub's type remarks to state plainly that JournalAsync does not sanitize at write time; a new row is safe only because the #4326/#4363 producers that build Detail are, not because the store enforces it on the way in. - DarlingWorker's catch around the scrub now logs the exception's type and SQLSTATE only, matching the type-and-SQLSTATE-only shape used elsewhere in this area, rather than the exception's own message. Darling.Tests builds clean (-p:EnableWindowsTargeting=true, 0 Warning(s), 0 Error(s)).
erikdarlingdata
added a commit
that referenced
this pull request
Sep 26, 2026
…33Z UTC (#4440) Moves the CHANGELOG entries carried in merged pull-request descriptions into [Unreleased]. The cut is PRs merged at or before 2026-09-26T17:37:33Z; the next splice starts after it. - 128 PRs are spliced: Fixed 87, Changed 20, Added 17 and Security 5. Each entry sits at the top of its section, newest PR first, and its link definition joins the trailing block. - 22 PRs have no user-visible entry (None, test-only, or deferred to a parent). - Three entries had no section in their description, and each was assigned from its diff: #4363, #4383 and #4380 go under Security. - Link fixes: the #4198 references point at the issue, and #4360's [#4203] label points at issue 4203. - #4208's entry is taken from its diff. - Two security entries are worded to the current state: #4351's rotation note, and #4363's journal-read line. - Only CHANGELOG.md changes.
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.
Refs #4346 (the one-time scrub of already-stored legacy rows follows in its own PR; #4346 stays open until it merges).
Why
#4326 stopped the plan-force bot's state-read failure note from putting
ex.Messageintocollect.plan_force_actions.detail. Rows a build before#4326 wrote still carry the raw exception text, and stay until the
retention purge (up to a year). Nothing calls
GetRecentActionsAsyncinthis build (
get_plan_force_actionsappears only in doc comments), but thedisposition ruling on #4346 is that whoever builds that read owns not
returning legacy
detailverbatim, so this fixes the read now rather thanwaiting for a caller to land first.
What changes
PgPlanForceActionStore.GetRecentActionsAsync(the read the doc commentsname as
get_plan_force_actions's backer) now runs every row'sdetailthrough a new
SanitizeDetailForAuditbefore returning it.There is no build marker on the row, so this is a positive allow-list, not
a before/after cutoff:
state_unavailableis the one blocker whoseevidence line ever carried exception text (
TryGetTargetStatesAsync's oldcatch, fixed by #4326's M1 round). The new gate matches ONLY the one line
shape #4326 can produce for it (a type name, plus SQLSTATE for a
DbException, plus one ofCollectionFailure.Describe's two fixed lognotes). Any
state_unavailable:line that does not match that templateverbatim — including every legacy row's raw exception message — is
replaced with a fixed sentence that names no exception text. Every other
blocker's evidence line (
parameter_sensitivity_cofired,secondary_replica_evidence,apc_owns_it,apc_enabled_for_database,and the empty-state shape of
state_unavailable) has never carriedexception text on any build and passes through untouched, as does
detailon non-blocked rows (
would_force's null, the withheld-force sentence).This is on the READ side only — no data rewrite of existing rows, per the
brief.
Lite parity: grepped for
plan_force_actionsandPlanForceActionStoreoutside Darling — no hits. Lite has no plan-force bot and no twin of this
table or read. N/A.
Other read surfaces: grepped the whole repo for
get_plan_force_actionsand
GetRecentActionsAsync—GetRecentActionsAsyncis the only productioncaller-eligible read of the column (
GetPendingReviewsAsyncis a different,uncalled read used only by #2731's future review path and is out of this
issue's scope — it also has no caller yet, so nothing serves it today
either; flagging it here rather than silently widening the diff). No MCP
tool, web route, or viewer route reads
collect.plan_force_actionstoday.Test plan
SanitizeDetailForAuditunit tests (PlanForceActionAuditRedactionTests.cs,net10.0 — ran locally): the three post-Stop leaking raw exception text from /api/ping and two MCP notes (#4316) #4326 safe shapes (type only, with
SQLSTATE, missing-schema note) pass through unchanged; a legacy row whose
exception text carries a fake credential and hostname is redacted to the
fixed sentence with none of that text surviving; an unrelated blocker line
on the same row is untouched; null and non-
state_unavailabledetail areuntouched.
dotnet build Darling/Darling.Tests/Darling.Tests.csproj -p:EnableWindowsTargeting=true— 0 warnings, 0 errors.on macOS; CI decides.
new-shaped row, run
GetRecentActionsAsync, compare) — not run in thislane; the unit tests above exercise the exact same regex/string logic the
SQL-side read calls, and the read itself does no SQL-side filtering (the
redaction is pure C# over the returned
detail), so the rig would proveplumbing, not new logic. Left unchecked per the brief's timebox; flagging
as a gap if the coordinator wants that extra proof.
CHANGELOG entry
For the coordinator
This is a secret-handling fix (exception text potentially carrying hosts,
credentials, relation names). Please consider it for a review round before
merge, per the brief's note.
GetRecentActionsAsynchas no productioncaller yet in this build (confirmed by grep), so this fix has no runtime
blast radius today — it closes the gap before a caller lands. The
uncalled
GetPendingReviewsAsyncread on the same table is sanitized too(see the lane report below): both journal reads apply
SanitizeDetailForAuditbefore returningdetail. No migration, no datarewrite.
pm-pr lane report (readers + anchoring)
Reader enumeration. Grepped the whole repo for the table name and its
detailcolumn (SQL textincluded:
SELECT,RETURNING,*) and for every placePlanForceActionRecord.Detailis read back inC#.
collect.plan_force_actionshas exactly two row readers in this build:PgPlanForceActionStore.GetRecentActionsAsync— the audit read behindget_plan_force_actions(no MCPtool wires it up yet, per
ServiceCommandDeadlines's own comment, but the doc-commented intent is thatit will). Already sanitized in the PR as opened; unchanged disposition here.
PgPlanForceActionStore.GetPendingReviewsAsync— the own-forces-only review read for Add auto force-plan bot phase 2: the write path (#2138) #2731's live-forcewrite path. No caller in this build either (also noted in
ServiceCommandDeadlines), and it ranReadRecordstraight into the return list with no sanitizer at all. Fixed in this round: it now passesevery row's
detailthrough the sameSanitizeDetailForAudit, at the one choke point (thewhile (await reader.ReadAsync(ct))loop), so no path out of this method can carrydetailaround it.No other reader exists:
JournalAsynconly INSERTs and returnsaction_id;GetQueryHistoryAsyncneverselects
detail;TryGetTargetStatesAsyncreads a different table viaDarlingForcePlanTargetStateReader.There is no web, WPF, export or Lite-side reader of this table at all —
PlanForceActionRecord,plan_force_actionsand the store class are Darling-only;Lite.Tests/OperatorRemediationLiteDivergencePinTestsasserts the absence of a Lite twin, so there is no Lite equivalent to sanitize. Both readers now sanitize;
per the ruling, a reader with no caller today still gets the treatment, since a caller added later must not
find an open path back to raw exception text.
The regex, as it now stands. The line-by-line gate in the PR as opened had a real gap: it split
detailon\nand checked each line's own text against the safe-shape regex, so a legacy row whoseraw exception message itself embedded a newline (a multi-line driver message, a wrapped stack fragment)
only had its first line checked — anything after that embedded newline passed through unexamined. Replaced
with a whole-BLOCK match:
with
RegexOptions.Singleline | RegexOptions.Multiline. This matches from thestate_unavailable:prefixup to (not including) the next known blocker's line or the end of the string, and the whole matched span
— not one line of it — is checked against
SafeStateUnavailableLine(still a full-line^…$anchor) beforebeing kept or replaced with the fixed sentence.
Singlelinelets.cross an embedded\nso the lazy bodycan span one;
Multilineanchors^to right after a\n(which also covers a\r\npair, since theposition immediately after
\ndoesn't change). A lone\r, and the Unicode LINE SEPARATOR (U+2028) /PARAGRAPH SEPARATOR (U+2029), are deliberately not line boundaries to .NET's
^/\zunder theseoptions, so text smuggled after one of those stays inside the block and is redacted with it, rather than
surviving past the anchor as a "new line."
New pins (
PlanForceActionAuditRedactionTests), RED/GREEN evidence. Proved with a throwaway net10.0console at
/tmp/sanichecklinking a verbatim copy of the sanitizer (the real class needs Npgsql/ILoggerthat aren't worth pulling into a standalone harness) — the test project itself is net10.0-windows and can't
run on this macOS host.
\n+ exception text) andboth halves of pin (c) (CRLF) went RED —
leaked...hunter2survived in the output, because the secondline never matched
state_unavailable:at its own start and was never checked. Pin (a) (safe prefix +trailing exception text, still on one line) was already caught by the original single-line anchor and
showed GREEN even on 99d3c02, since a full-line
^…$regex rejects any line with extra trailing text.case, which must still pass, and the CRLF-smuggled case, which must redact), plus the lone-
\r,U+2028 and U+2029 smuggling cases, and every pre-existing pin (three post-fix shapes, the original legacy
pin, the sibling-blocker-line survives, non-
state_unavailabledetail unchanged, null stays null) —ALL PASS (16/16 in the harness; mirrored 1:1 as the 8 new xUnit facts added to
PlanForceActionAuditRedactionTests.cs, plus the 7 pre-existing ones, 15 total in that file as ofhead
7ddba0d3— corrected count per the security round's F6; see the new section below for thefurther F1/F2 changes to this file, which bring the total to 18).
New head sha:
7ddba0d3(merge oforigin/devonto99d3c02b, no conflicts — dev had touchedunrelated files:
ComposeStoreRolesLiveTests,PgFileSettingsCapability*,PgServerConfigPendingRestartLiveTests,etc.). PR head confirmed still
99d3c02bimmediately before this push.Build/test status.
dotnet build Darling/Darling.Tests/Darling.Tests.csproj -p:EnableWindowsTargeting=true:0 Warning(s), 0 Error(s), both before and after the merge from dev.
Darling.Tests.dllisnet10.0-windows and refuses to launch on this macOS host (
Microsoft.WindowsDesktop.Appnot present) — thesanitizer pins were proven with the standalone harness above instead; CI's
buildjob runs the actual xUnitfacts.
DocCommentHygiene*was not run locally for the same host reason; left to CI.3 riskiest changed lines (for the security round that follows):
StateUnavailableBlockregex's lookahead alternation (the four sibling-blocker prefixes hardcodedinto the pattern) — if a new blocker name is ever added to
ForcePlanBotPolicywithout a matching entryhere, that blocker's own line would be swallowed into an adjacent unsafe
state_unavailableblock'sreplacement instead of surviving untouched.
SafeStateUnavailableLine's reliance onCollectionFailure.Describe's exact two fixed log notes as anallow-list — if that method's wording ever changes without updating this regex, every future
state_unavailablerow (not just legacy ones) starts failing the match and gets replaced with the fixedsentence, which is a silent behavior change (safe direction, but loses real evidence).
GetPendingReviewsAsync's newrecord with { Detail = SanitizeDetailForAudit(record.Detail) }— thewith-expression rebuild is easy to lose in a future refactor of that method (e.g. someone inliningReadRecord's call site) since nothing at the type level forces the sanitizer to run.CHANGELOG entry: kept as the single line above in "## CHANGELOG entry", corrected for F2 (both
journal reads, both fixed shapes untouched, no data rewrite).
pm-pr lane report (security fixes F1, F2, F6)
New head sha:
d5300856(merge oforigin/devonto7ddba0d3, keeping both sides, no conflicts —dev had touched
WebFetchLayerTests.cs,DarlingWebEndpoints.cs,DarlingMcpHealthParserTools.cs, andthree Lite/Viewer wwwroot files, none overlapping this PR's files). PR head confirmed
7ddba0d3immediatelybefore this round's push.
F1 (HIGH) — writer-order evidence, confirmed by reading the code (not just cited from the review):
ForcePlanBotPolicy.Blockers(PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs~291-317) addsapc_enabled_for_databaseonly understate is { ApcIsOn: true }, andstate_unavailableonly understate is nullorstate.IsEmpty— mutually exclusive with the APC-blocker's non-null-state requirement.FactRemediation.ForcePlanBlockers(PerformanceMonitor.Analysis/FactRemediation.cs~1019-1043) addsapc_owns_itonly afterif (state is null || state.IsEmpty) return blockers;— same gate. Soapc_owns_it/apc_enabled_for_databasecan never co-occur withstate_unavailable, andForcePlanBotPolicy.Blockersalways appendsstate_unavailableLAST when it fires at all. The block istherefore always the tail of
detail— anchoringStateUnavailableBlockto\zinstead of asibling-prefix lookahead is safe and removes the bypass.
F1 fix.
StateUnavailableBlockchanged from^state_unavailable:.*?(?=\r?\n(?:...|state_unavailable):|\z)(lookahead-bounded) to^state_unavailable:.*\z(runs to end-of-string), still
RegexOptions.Singleline | RegexOptions.Multiline.SiblingBlockerLine_ SurvivesEvenWithAdjacentRedaction, which pinned an order the writer never produces (state_unavailableBEFORE
apc_owns_it), replaced withLegacyLine_EmbeddingASiblingPrefix_IsRedactedToTheEnd, pinning thewriter's real order (sibling first) and asserting the impersonating
\napc_owns_it:...hunter2...text isredacted along with the rest of the block.
F2 (MEDIUM) fix.
SafeStateUnavailableLinewidened from one shape to the three-branch allow-listcovering the read-failure line, the null-state "returned no row" line, and the empty-state "ran and
observed nothing" line (the common production case), each anchored
\A...\z. New pinsNoRowShape_FromTheWriter_PassesThroughUnchangedandEmptyStateShape_FromTheWriter_PassesThroughUnchangedbuild their input by calling
ForcePlanBotPolicy.Blockers/Evidencedirectly against a realForcePlanTarget/ForcePlanTargetState, not copied literals, so writer wording drift turns them red.Added
BothReads_ApplyTheSameSanitizeras the missing pin that bothGetRecentActionsAsyncandGetPendingReviewsAsyncsanitize (both already calledSanitizeDetailForAuditat theirrecord with { Detail = ... }sites before this round — confirmed by reading both methods; this round only adds the pin, nonew call site was needed).
RED/GREEN evidence. Ran on macOS via a throwaway net10.0 console project linking a verbatim copy of
both the old (7ddba0d) and new regex pairs, with fake secrets only:
apc_owns_it: ...\nstate_unavailable: ...(...\napc_owns_it: host=db-fake-01.example password=hunter2)...): RED on the old regex —hunter2anddb-fake-01survive in the output.GREEN on the new regex — both redacted, and the leading
apc_owns_it:sibling line survives intact.... returned no row for plan 7 of query 42 in db1; ...): RED on the old regex —rewritten to the generic "read failed" sentence. GREEN on the new regex — passes through byte-identical.
... ran and observed nothing for this target inside the last 24 hours: ...):RED on the old regex — same false rewrite. GREEN on the new regex — unchanged.
regression).
Build.
dotnet build Darling/Darling.Tests/Darling.Tests.csproj: 0 Warning(s), 0 Error(s), bothbefore and after
git merge origin/dev. The xUnit facts themselves are net10.0-windows and cannot run onthis macOS host; CI's
buildjob runs them.PlanForceActionAuditRedactionTests.csnow has 18[Fact]s(15 pre-existing minus the one inverted for F1, plus 4 new: 1 for F1, 2 for F2, 1 for the missing
both-reads pin).
F6. Corrected the "left alone" claim about
GetPendingReviewsAsync(both reads are sanitized), collapsedto one CHANGELOG line matching the review's corrected wording (both reads, both new-shape lines untouched, no
data rewrite), and fixed the stated fact count to match the file.
Not touched, per the brief: F3 (the ~365-day retention window and
pg_dumpbackups still carry the rawtext — a follow-up, filed as its own issue is Erik's call) and F4 (moving the sanitizer into
ReadRecordasa single choke point — both call sites already sanitize correctly today, so this is a defensive follow-up,
not a defect).