Skip to content

Mask secrets in pipeline-level logging - #3581

Merged
thomhurst merged 13 commits into
mainfrom
issue-3476-secret-logging
Aug 2, 2026
Merged

Mask secrets in pipeline-level logging#3581
thomhurst merged 13 commits into
mainfrom
issue-3476-secret-logging

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Summary

  • mask dry-run command inputs before logging
  • apply structured-state and scope masking to pipeline-level logs
  • replace raw exceptions with obfuscated downstream-safe exceptions

Test plan

  • focused logging tests (89 passed)
  • ModularPipelines.sln Release build (0 errors)
  • touched-file whitespace verification

Closes #3476

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

This is a solid, well-targeted fix for #3476 — it closes all three holes the issue called out (dry-run input, PipelineLevelLogger bypassing the obfuscation pipeline entirely, and raw exceptions leaking into downstream providers), and the new tests (Masks_Secret_Values_From_Dry_Run_Command, PipelineLevelLoggerTests, FormattedLogValuesObfuscatorTests) exercise the new behavior directly rather than just re-asserting the old ones. No bugs or CLAUDE.md violations found. Two design points worth considering as follow-ups, neither blocking:

1. PipelineLevelLogger.Log lost the IsEnabled short-circuit that its sibling has

/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var obfuscatedState = _formattedLogValuesObfuscator.TryObfuscateValues(state!);
new BufferedLogEvent<TState>(
logLevel,
eventId,
state,
obfuscatedState,
exception,
formatter,
_secretObfuscator)
.WriteTo(_logger);
}

Log<TState> now unconditionally calls TryObfuscateValues and builds a BufferedLogEvent<TState> (which itself eagerly obfuscates exception.ToString() in ObfuscatedLogException's constructor) before ever checking whether the log level is enabled. Compare with ModuleLogger<T>.Log, which is the other implementation of the same IModuleLogger.Log contract and guards with if (!IsEnabled(logLevel)) return; before doing any obfuscation work — and with CommandLogger.LogDryRunCommand in this very PR, which adds exactly that !logger.IsEnabled(LogLevel.Information) guard for the same reason (avoid obfuscation cost when nothing will be written).

Since PipelineLevelLogger is what backs all logging outside a module context (pipeline init, hooks, condition evaluation — per ModuleLoggerProvider.GetLogger()), any Trace/Debug-level calls there now pay for a full state/exception obfuscation pass even when filtered out by LoggerFilterOptions. Adding the same IsEnabled guard at the top of Log<TState> would make the two IModuleLogger implementations consistent and avoid the wasted work — the same fix already applied to CommandLogger in this PR.

2. ObfuscatedLogException discards the original exception's type identity

internal sealed class ObfuscatedLogException : Exception
{
private readonly string _obfuscatedText;
private ObfuscatedLogException(Exception exception, ISecretObfuscator secretObfuscator)
: base(secretObfuscator.Obfuscate(exception.Message, null))
{
_obfuscatedText = secretObfuscator.Obfuscate(exception.ToString(), null);
HResult = exception.HResult;
}
public static Exception? Create(Exception? exception, ISecretObfuscator secretObfuscator)
=> exception is null
? null
: new ObfuscatedLogException(exception, secretObfuscator);
public override string ToString() => _obfuscatedText;
}

Every exception that reaches a downstream ILogger (module or pipeline-level) is now replaced with a plain ObfuscatedLogException. Text-based sinks are fine — ToString() is overridden and still embeds the original type name (covered by the nameof(InvalidOperationException) assertion in ModuleOutputBufferTests) — but anything that inspects the exception object itself (is OperationCanceledException checks, Sentry/Application Insights grouping/sampling by exception type, custom retry or alerting logic keyed off exception type in a downstream logging provider) will now see every exception as the same ObfuscatedLogException type, silently changing that behavior for consumers of this library's logs.

This is a reasonable tradeoff for closing the secret leak (and matches what the issue itself suggested — "an obfuscated-ToString exception decorator"), so not asking for a change here, but it's worth a one-line doc comment on ObfuscatedLogException noting that type identity is intentionally not preserved, so a future maintainer doesn't assume GetType() still reflects the original exception when debugging a downstream integration.

@thomhurst

Copy link
Copy Markdown
Owner Author

Addressed review notes: disabled pipeline log levels now short-circuit before obfuscation, with regression coverage; exception type-identity replacement is documented. @codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

obfuscatedValues ??= values.ToArray();
obfuscatedValues[index] = new KeyValuePair<string, object?>(property.Key, obfuscatedValue);

P2 Badge Preserve formatted scope rendering after masking

When a structured scope such as BeginScope("Processing {Token}", secret) contains a value that is masked, this replaces its FormattedLogValues state with a raw KeyValuePair[]. Because BeginScope has no formatter, text-oriented providers render that array's type name instead of the original scope text (for example, Processing ********). Preserve a sanitized state object with the original formatted ToString() behavior while exposing the masked key/value pairs.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/PipelineLevelLogger.cs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Both follow-ups from the last review round are genuinely resolved now, not just cosmetically:

  • PipelineLevelLogger.Log (src/ModularPipelines/Logging/PipelineLevelLogger.cs:220-223) now short-circuits with IsEnabled(logLevel) before doing any obfuscation work, matching ModuleLogger<T> and the new CommandLogger.LogDryRunCommand guard — and it's covered by Log_DoesNotObfuscateWhenDisabled, which asserts the obfuscator is never invoked.
  • ObfuscatedLogException (src/ModularPipelines/Logging/ObfuscatedLogException.cs:163-168) now carries a <remarks> doc noting type identity is intentionally dropped.

I re-read the rest of the diff (BufferedLogEvent's new typed-state branch, FormattedLogValuesObfuscator.ObfuscateValue, the dry-run input masking, ModuleLoggerProvider DI wiring) and didn't find anything that reintroduces a secret leak or breaks the existing behavior. No blocking issues. Two minor, non-blocking follow-ups worth a look:

1. ObfuscatedLogException loses stack trace, inner-exception chain, and Data — not just type identity

/// <remarks>
/// Wrapping intentionally replaces the original exception type identity.
/// </remarks>
internal sealed class ObfuscatedLogException : Exception
{
private readonly string _obfuscatedText;
private ObfuscatedLogException(Exception exception, ISecretObfuscator secretObfuscator)
: base(secretObfuscator.Obfuscate(exception.Message, null))
{
_obfuscatedText = secretObfuscator.Obfuscate(exception.ToString(), null);
HResult = exception.HResult;
}
public static Exception? Create(Exception? exception, ISecretObfuscator secretObfuscator)
=> exception is null
? null
: new ObfuscatedLogException(exception, secretObfuscator);

Since the wrapper is only ever new'd and never thrown, ObfuscatedLogException.StackTrace is always null, and InnerException/Data are never populated from the source exception. The full text (including the original stack trace) is preserved in the overridden ToString(), so nothing about the secret-leak fix is weakened — but any downstream sink that inspects the exception object directly rather than calling ToString() (Application Insights TrackException, Sentry, structured JSON exporters that walk InnerException/StackTrace for grouping and alerting) will lose that structure for every exception that passes through pipeline or module logging, which is a bigger observability hit than the type-identity change already called out. Worth either mentioning this explicitly alongside the existing type-identity remark, or considering copying Data/wrapping via InnerException = exception (the outer Message/ToString() behavior wouldn't need to change) so structured sinks at least retain the original exception in the chain.

2. FormattedLogValuesObfuscator.ObfuscateValue assumes a non-null state

https://github.com/thomhurst/ModularPipelines/blob/7229143e62ca4ba3676abe269d2526cef2e54ab8/src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs#L100-L107

TryObfuscateValues(object state) now falls into ObfuscateValue(state) for any non-structured state, which unconditionally calls value.ToString(). IModuleLogger.Log<TState> (like the BCL ILogger.Log<TState>) doesn't constrain TState : notnull, unlike BeginScope, which does. If a caller ever logs with a null state, this now throws an NRE from inside the obfuscation path itself — previously it was a harmless no-op (return state). Low likelihood in practice since MEL's own LogXxx extensions always build a non-null FormattedLogValues, but given this method's entire job is to safely pre-process arbitrary logger state before it reaches a sink, a value?.ToString() ?? string.Empty guard would make it robust against that edge case for free.

Nice test coverage on the new behavior (Masks_Secret_Values_From_Dry_Run_Command, TryObfuscateValues_MasksUnstructuredState, Log_ObfuscatesStateMessageAndExceptionBeforeDelegating, BeginScope_ObfuscatesStateBeforeDelegating) — these exercise the actual masking outcome rather than just re-asserting old behavior.

Sanitize dry-run commands, pipeline-level structured state, scopes, and exceptions before downstream providers can render them.

Closes #3476
@thomhurst
thomhurst force-pushed the issue-3476-secret-logging branch from 7229143 to 99a80fc Compare July 31, 2026 02:52
@thomhurst

Copy link
Copy Markdown
Owner Author

Addressed the null-state review finding in 99a80fc. Focused logging tests pass 20/20; core Release build passes. @codex review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Re-reviewed the current head (99a80fc19). Both fixes from the last two review rounds hold up:

  • PipelineLevelLogger.Log short-circuits on IsEnabled(logLevel) before doing any obfuscation work (src/ModularPipelines/Logging/PipelineLevelLogger.cs:244-248), covered by Log_DoesNotObfuscateWhenDisabled.
  • The null-state NRE is fixed — ModuleLogger.Log and PipelineLevelLogger.Log both guard state is null before calling TryObfuscateValues (ModuleLogger.cs:129-131, PipelineLevelLogger.cs:249-251), and FormattedLogValuesObfuscatorTests/Log_PreservesNullState exercise it.
  • BufferedLogEvent<TState>.WriteTo now wraps the exception via ObfuscatedLogException.Create before it ever reaches a downstream ILogger.Log call, closing the raw-exception leak the issue called out, and Format still routes the fully-rendered message through secretObfuscator.Obfuscate regardless of which exception/state variant is used — so the message text stays safe even though Format intentionally formats off the original (unwrapped) exception via closure rather than the logException parameter.

Codex's P2 (structured scope rendering) still looks unaddressed

FormattedLogValuesObfuscator.TryObfuscateValues (src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs:35-58) still replaces a masked structured state with a raw KeyValuePair<string, object?>[] when any value changes:

obfuscatedValues ??= values.ToArray();
obfuscatedValues[index] = new KeyValuePair<string, object?>(property.Key, obfuscatedValue);
...
return obfuscatedValues ?? state;

For logger.BeginScope("Processing {Token}", secret), the underlying state MEL builds is FormattedLogValues, which implements IReadOnlyList<KeyValuePair<string, object?>> and overrides ToString() to render the templated message (e.g. "Processing ********" if it had been given obfuscated args). Once a secret is found, PipelineLevelLogger.BeginScope (PipelineLevelLogger.cs:264-273) hands the downstream logger a bare array instead of that FormattedLogValues-shaped object. Any provider that renders scopes via scope.ToString() (console/file scope prefixes are the common case) loses the templated rendering and instead prints the array's default ToString() (its type name) — not a secret leak, but a real regression in scope legibility that's easy to miss since BeginScope_ObfuscatesStateBeforeDelegating only exercises the plain-interpolated-string path ($"Scope: {secret}"), not the templated BeginScope(format, args) overload that actually produces a FormattedLogValues. Worth either preserving a sanitized state object that still renders correctly via ToString() (as Codex suggested), or adding a regression test for the templated BeginScope overload if the current array-return behavior is intentional.

Non-blocking, previously raised

ObfuscatedLogException (src/ModularPipelines/Logging/ObfuscatedLogException.cs) still only preserves HResult and a rendered ToString()StackTrace, InnerException, and Data from the original exception are dropped for every exception that passes through module/pipeline logging. Text sinks are unaffected (the full original text, including stack trace, survives in ToString()), but anything that inspects the exception object structurally (APM exception grouping, structured JSON exporters walking InnerException) loses that structure. Already flagged as an acceptable tradeoff in the prior round, so raising only as a reminder, not a blocker.

Nothing else stood out — the DI wiring in ModuleLoggerProvider, the dry-run input masking in CommandLogger, and the new/updated tests all look consistent with the stated goal of closing #3476.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99a80fc194

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/PipelineLevelLogger.cs
@thomhurst

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19d825017e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs Outdated
@thomhurst

Copy link
Copy Markdown
Owner Author

Preserved sanitized exception diagnostics in 53d6bf7: recursive inner exceptions, original stack/target frame, Source, HelpLink, and HResult now reach downstream providers without raw secret-bearing exception references. Validation: PipelineLevelLoggerTests 10/10; ModuleOutputBufferTests 23/23; scoped whitespace clean. @codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53d6bf70f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66783d052b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs Outdated
Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs
@thomhurst

Copy link
Copy Markdown
Owner Author

@claude review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef1c2784cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/PipelineLevelLogger.cs Outdated
Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs Outdated
@thomhurst

Copy link
Copy Markdown
Owner Author

Also fixed the Native AOT smoke crash in 8838cc5: TargetSite private-field copying is skipped when dynamic code is unsupported. @claude review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8838cc53f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs Outdated
Comment thread src/ModularPipelines/Logging/ObfuscatedLogException.cs
@thomhurst

Copy link
Copy Markdown
Owner Author

@claude review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfa851e0c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/PipelineLevelLogger.cs Outdated
@thomhurst

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08072259a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Logging/PipelineLevelLogger.cs Outdated
Fall back to a fully masked event when structured state inspection throws, avoiding both unsafe traversal and the original formatter.

Refs #3476

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Re-reviewed the current head (c702dd7). This PR has gone through a lot of hardening rounds now (IsEnabled short-circuits, null-state handling, structured-scope rendering, hostile-exception diagnostics), and each previously-flagged issue I can find genuinely holds up on re-check — no secret-leak regressions spotted in the diff since the last review.

One real gap, plus one architectural observation for the pattern that's produced most of this PR's churn:

1. The just-added "hostile structured state" guard only covers PipelineLevelLogger, not ModuleLogger<T> — the far more common path

public override void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string>? formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
lock (_disposeLock)
{
if (_isDisposed)
{
return;
}
var obfuscatedState = state is null
? null
: _formattedLogValuesObfuscator.TryObfuscateValues(state);
var logEvent = new BufferedLogEvent<TState>(
logLevel,
eventId,
state,
obfuscatedState,
exception,
formatter ?? (static (_, _) => string.Empty),
_secretObfuscator);
// Write to buffer for ordered module output during pipeline execution.
// Output will be flushed to console and loggers when the module completes.
_buffer.AddLogEvent(logEvent);
}
}

The latest commit (c702dd7, "guard hostile log state") wraps _formattedLogValuesObfuscator.TryObfuscateValues(state) in a try/catch inside PipelineLevelLogger.Log, falling back to a fully-masked event when a structured state's Count/indexer throws — with Log_GuardsHostileStructuredTraversal covering it. But ModuleLogger<T>.Log, the other IModuleLogger implementation of the same contract, calls the exact same _formattedLogValuesObfuscator.TryObfuscateValues(state) unguarded (ModuleLogger.cs:122-124) before building its BufferedLogEvent. There's no equivalent test in ModuleLoggerTests.cs.

This isn't just an inconsistency — ModuleLoggerProvider.GetLogger() (ModuleLoggerProvider.cs:63-100) only falls back to PipelineLevelLogger when there's no module context; every log call made from inside a module (i.e. nearly all real-world logging in a pipeline) goes through the unguarded ModuleLogger<T> path. A module that logs a custom IReadOnlyList<KeyValuePair<string,object?>> state whose Count or indexer throws (or any other structured state FormattedLogValuesObfuscator.TryObfuscateValues can't safely traverse) will throw straight out of the module's LogInformation/LogWarning call, which is exactly the failure mode the last several commits were dedicated to eliminating for pipeline-level logging.

Why this keeps happening: this is the third or fourth time in this PR's history that a fix landed in one of ModuleLogger/PipelineLevelLogger/CommandLogger and had to be re-applied to a sibling later (IsEnabled short-circuit, null-state handling, and now this). All three duplicate the same "obfuscate state defensively before handing it to the underlying ILogger" logic independently. Rather than adding a fourth try/catch to ModuleLogger.Log, it'd be worth pulling this into one shared helper (e.g. have IFormattedLogValuesObfuscator.TryObfuscateValues itself catch and return a masked fallback, or add a single SafeObfuscateState(state) used by both Log implementations) so hardening added in one place can't be forgotten in the other. That would also shrink the surface area future secret-leak fixes need to touch.

2. (Non-blocking) ObfuscatedLogException's reliance on UnsafeAccessor into private BCL fields is worth watching

[UnconditionalSuppressMessage(
"Trimming",
"IL2026",
Justification = "TargetSite is copied as an optional diagnostic; missing trimmed metadata is acceptable.")]
private static MethodBase? GetTargetSite(Exception exception) => exception.TargetSite;
// TargetSite is non-virtual and has no public copy API; this is its .NET 10 backing field.
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_exceptionMethod")]
private static extern ref MethodBase? GetExceptionMethod(Exception exception);
// NativeAOT computes TargetSite from the first captured native stack trace entry.
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_corDbgStackTrace")]
private static extern ref IntPtr[]? GetNativeAotStackTrace(Exception exception);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_idxFirstFreeStackTraceEntry")]
private static extern ref int GetNativeAotStackTraceCount(Exception exception);

TryCopyTargetSite/TryCopyNativeAotTargetSiteState reach into Exception's private _exceptionMethod, _corDbgStackTrace, and _idxFirstFreeStackTraceEntry fields via [UnsafeAccessor] to preserve TargetSite across the wrap. It's already defensively caught (MissingFieldException), so it degrades gracefully rather than crashing — but these are undocumented CoreCLR/NativeAOT implementation details, not a public contract, so they can silently stop copying (falling back to null TargetSite) on a future runtime or on Mono without any signal. Given how much of this file's complexity (244 lines, most of it added across the last ~8 commits) exists to preserve exception structure field-by-field, it may be worth reconsidering the overall approach — e.g. wrapping/decorating the original exception (keep it as InnerException or similar, only override the rendered text) instead of reconstructing its internal state — rather than continuing to chase individual fields as they're noticed missing. Not asking for a change in this PR; flagging it since the pattern of "one more field to copy" has been the shape of several commits here already.

Nothing else stood out — dry-run masking, the ObfuscatedScopeState fix for templated BeginScope, and the DI wiring all look correct and are covered by tests that exercise real outcomes rather than re-asserting prior behavior.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c702dd741c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ModularPipelines/Console/ModuleOutputBuffer.cs
Replay null buffered states with the original generic type so logger providers observe the same event contract.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Re-reviewed the current head (13b31e0a). The newest commit (13b31e0aae, "preserve null state type") correctly fixes the last outstanding Codex finding — buffered null states now replay through logger.Log<TState> with the original generic type (ModuleOutputBuffer.cs:776-784), verified by ModuleOutputBufferTests. No new secret-leak regressions in this round.

One point from my previous review round does not appear to have been addressed, and it's the same architectural issue this PR keeps running into, so flagging it again as the one thing worth resolving before merge:

ModuleLogger<T>.Log still lacks the hostile-state guard that PipelineLevelLogger.Log has

src/ModularPipelines/Logging/PipelineLevelLogger.cs:42-61 wraps _formattedLogValuesObfuscator.TryObfuscateValues(state) in a try/catch (added in c702dd741c, "guard hostile log state") — if a custom structured state's Count or indexer throws, it falls back to a fully masked BufferedLogEvent instead of propagating.

src/ModularPipelines/Logging/ModuleLogger.cs:122-124 calls the exact same method, completely unguarded:

var obfuscatedState = state is null
    ? null
    : _formattedLogValuesObfuscator.TryObfuscateValues(state);

And FormattedLogValuesObfuscator.TryObfuscateValues (FormattedLogValuesObfuscator.cs:32-58) iterates values.Count / values[index] directly, with no try/catch around that traversal — only the inner ObfuscateValue's ToString() call is guarded. A module that logs a custom IReadOnlyList<KeyValuePair<string,object?>> state whose Count or indexer throws will now throw straight out of LogInformation/LogWarning, aborting the module — exactly the failure mode the last several commits were dedicated to eliminating, just not for this path.

This isn't a minor oversight: per ModuleLoggerProvider.GetLogger(), ModuleLogger<T> is the logger used for essentially all in-module logging, while PipelineLevelLogger only backs the narrow pre/post-module-context window (init, hooks, condition evaluation). The guard landed on the rarer path and not the common one, and there's no equivalent regression test in ModuleLoggerTests.cs (that file isn't touched by this PR at all) — the closest existing test, PipelineLevelLoggerTests.Log_GuardsHostileStructuredTraversal, only exercises PipelineLevelLogger.

Why this keeps recurring: this is now the fourth piece of hardening (IsEnabled short-circuit, null-state handling, structured-scope rendering, and now this) that landed on one of ModuleLogger/PipelineLevelLogger/CommandLogger and had to be independently re-discovered for a sibling. Rather than adding a fifth try/catch to ModuleLogger.Log, it would close off this whole class of gap to push the defensive behavior into IFormattedLogValuesObfuscator.TryObfuscateValues itself (catch internally, return a safe fallback) so both Log implementations get the hardening for free and future changes only need to touch one place. Given how much of this PR's churn has been chasing the same fix across duplicated call sites, that consolidation would pay for itself quickly.

Non-blocking, previously raised, still true

ObfuscatedLogException's TargetSite preservation relies on [UnsafeAccessor] into private Exception fields (_exceptionMethod, _corDbgStackTrace, _idxFirstFreeStackTraceEntry). It degrades gracefully (caught MissingFieldException) so it's not a blocker, but these are undocumented runtime internals that could silently stop working on a future CoreCLR/NativeAOT/Mono version. Worth keeping in mind if this file needs another round of "preserve one more field" fixes — a wrap-don't-reconstruct approach (keep the original exception reachable, e.g. as InnerException, and only override the rendered text) would be more resilient than continuing to mirror BCL internals field-by-field.

Everything else — dry-run input masking, the ObfuscatedScopeState fix for templated BeginScope, AggregateException handling, Data copying, and the DI wiring in ModuleLoggerProvider — looks correct and is exercised by tests that check real outcomes rather than re-asserting prior behavior.

@thomhurst
thomhurst merged commit a250708 into main Aug 2, 2026
15 checks passed
@thomhurst
thomhurst deleted the issue-3476-secret-logging branch August 2, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Logging: PipelineLevelLogger bypasses secret masking; dry-run command logging is unmasked

1 participant