Skip to content

fix(cache): let a cache mask its keys in the log - #160

Closed
cosmin-staicu wants to merge 1 commit into
mainfrom
fix/log-keys-redaction
Closed

fix(cache): let a cache mask its keys in the log#160
cosmin-staicu wants to merge 1 commit into
mainfrom
fix/log-keys-redaction

Conversation

@cosmin-staicu

@cosmin-staicu cosmin-staicu commented Sep 8, 2026

Copy link
Copy Markdown
Member

Closes #129.

Problem

Cache keys are written to logs, several of them at Warning. For the application's own caches that is usually harmless. For the IDistributedCache adapter it is not: those keys are the consumer's and can be secrets, such as the ASP.NET Core session id. A Redis blip raised the inner-cache warning paths and wrote session identifiers to the log (CWE-532).

Change

  • ICacheOptions.LogKeys, default true, on InMemoryCacheOptions, InMemoryRedisCacheOptions and RedisCacheOptions. The interface member has a default implementation, so hand-written ICacheOptions implementers keep compiling.
  • Masking keeps the prefix and hides the key. A masked key keeps whatever the key strategy composed in front of the caller's key and shows the first three characters of the key followed by ****: d:ses**** at the cache layer, myapp:dh:d:ses**** at the Redis layer. Each layer learns its prefix by composing a probe key through its own strategy; a custom strategy whose output does not end with the key yields no prefix, and the whole value is masked, since nothing in it is known to be safe. The Redis layer adds the cache-key prefix on top of its own. The adapter hands its own d: prefix to the tier copies it configures, because it neutralizes their key strategy.
  • LoggedKey, an internal struct whose ToString does the masking. The LoggerMessage generator only calls ToString when the level is enabled, so a masked Trace site on a hot path costs nothing when Trace is off.
  • Every generated log method that took a CacheKey or RedisKey now takes a LoggedKey and is named Log…Core; a same-named Log…(CacheKey) wrapper beside it applies the cache's switch. The 100-odd call sites are untouched, and a new call site cannot bypass the switch: a raw key does not convert to LoggedKey. Applied mechanically to MultilayerCache (22 methods), MultilayerHashCache (16), RedisCache (5), RedisHashCache (3) and CacheEventPublisher (1); the four joined-keys sites go through the same helper. Not done via CacheKey.ToString(), which the issue rules out because the implicit string conversion is used to compose physical keys.
  • The adapter sets LogKeys = false on the private copies of its tier's options, next to the existing CacheKeyStrategy neutralization, and masks the key in its own two messages.

Tests

  • LoggedKeyTests: verbatim, prefix kept and key masked, case-insensitive prefix match, whole-value masking without a matching prefix, short keys, prefix learning for the Redis and cache-key strategies including one that does not keep the key as a tail, join.
  • KeyLoggingTests: real caches through the container with a Trace-level capturing logger. With the switch off, no line contains the key and lines show sec****; with it on, the reverse. Same for the hash cache. The distributed adapter on the InMemory tier logs d:Sec**** even with the tier's own switch left at its default.
  • RedisCacheTests and RedisHashCacheTests: the miss line follows the switch, using the mocked logger.
  • UiPathDistributedCacheTests: the failed-write warning asserts the masked key and the absence of the raw one.

Docs: LogKeys rows in the three provider tables, the distributed-cache note rewritten from "keys appear in logs" to what now happens, appsettings.all.json gains the setting, changelog under Fixed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V9jRarnMLeZRZ5rYySRhkh

@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off.

Strong signals

  • adds public API surface (PublicAPI.Unshipped.txt in src/UiPath.Caching)

This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.

  • If a CLA is needed → add the cla-required label (a contributor comment with signing steps is posted automatically).
  • If it is not needed → replace needs-cla-review with cla-not-required so later pushes don't re-flag it.

@cosmin-staicu
cosmin-staicu force-pushed the fix/log-keys-redaction branch 8 times, most recently from 5a8db68 to 078dc8f Compare September 8, 2026 16:55
@cosmin-staicu cosmin-staicu changed the title fix(cache): let a cache keep its keys out of the log fix(cache): let a cache mask its keys in the log Sep 8, 2026
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 9, 2026 15:12

Copilot AI 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.

🟡 Changes recommended

Several Warning, Error, Debug, and Trace paths still log unmasked keys when masking is enabled.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds configurable cache-key masking to reduce sensitive-key exposure in logs.

Changes:

  • Introduces LogKeys and deferred LoggedKey masking.
  • Applies masking across multilayer, Redis, event-publisher, and distributed-cache logging.
  • Adds configuration documentation and tests.
File summaries
File Description
tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs Tests Redis hash-cache miss logging.
tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs Tests Redis cache miss logging.
tests/UiPath.Caching.Tests/LoggedKeyTests.cs Tests masking and prefix detection.
tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs Verifies adapter warning masking.
tests/UiPath.Caching.Tests/Config/KeyLoggingTests.cs Adds container-level logging tests.
src/UiPath.Caching/Redis/RedisHashCache.cs Masks Redis hash-cache keys.
src/UiPath.Caching/Redis/RedisCacheOptions.cs Adds Redis logging options.
src/UiPath.Caching/Redis/RedisCacheBase.cs Centralizes Redis-key rendering.
src/UiPath.Caching/Redis/RedisCache.cs Masks Redis cache log arguments.
src/UiPath.Caching/PublicAPI.Unshipped.txt Records new public properties.
src/UiPath.Caching/MultilayerHashCache.cs Masks multilayer hash-cache keys.
src/UiPath.Caching/MultilayerCacheBase.cs Centralizes multilayer masking.
src/UiPath.Caching/MultilayerCache.cs Masks single and joined keys.
src/UiPath.Caching/LoggedKey.cs Implements masked key rendering.
src/UiPath.Caching/InMemoryRedisCacheOptions.cs Adds key-logging configuration.
src/UiPath.Caching/InMemoryCacheOptions.cs Adds key-logging configuration.
src/UiPath.Caching/ICacheOptions.cs Defines the LogKeys API.
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs Masks adapter-owned messages.
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs Disables raw logging for adapter tiers.
src/UiPath.Caching/CacheEventPublisher.cs Masks event-publisher keys.
samples/UiPath.Caching.Sample/appsettings.all.json Demonstrates LogKeys settings.
docs/reference/settings.md Documents masking behavior.
CHANGELOG.md Records the security fix.
Review details
  • Files reviewed: 23/23 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/UiPath.Caching/ICacheOptions.cs Outdated
Comment on lines +26 to +31
/// Whether log lines show the cache key in full. Off, every site that would log a key keeps the prefix
/// the key strategy composed and masks the caller's key to its first three characters
/// (<c>myapp:s:cos****</c>). Turn it off where keys can be secrets — session ids, API keys — which is what
/// <c>AddDistributedCache</c> does for its own provider.
/// </summary>
public bool LogKeys { get => true; set => _ = value; }
Comment on lines +119 to +121
if (!await StoreAsync(cacheKey, fields, ttl, token).ConfigureAwait(false))
{
LogWriteNotApplied(key);
LogWriteNotApplied(LoggedKey.Mask(key));
private protected string? RedisKeyLogPrefix { get; set; }

// The physical key is the Redis prefix followed by the cache key, which carries its own prefix; without the first, nothing is known to be safe.
private protected LoggedKey Logged(RedisKey key) => new(key.ToString(), RedisKeyLogPrefix is null ? null : RedisKeyLogPrefix + _cacheKeyLogPrefix, LogKeys);

Copilot AI 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.

🟡 Changes recommended

Several warning, error, and broadcast paths still log raw consumer keys despite LogKeys being disabled.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 23/23 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +81 to +83
_keyLogPrefix = (multiLayerCacheOptions as IKeyLogPrefixSource)?.KeyLogPrefix
?? LoggedKey.PrefixOf(multiLayerCacheOptions.CacheKeyStrategy ?? new DefaultCacheKeyStrategy());
_eventPublisher = new CacheEventPublisher(cacheName, _topicProvider, cacheEventFactory, logger, multiLayerCacheOptions.LogKeys, _keyLogPrefix);
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

ICacheOptions.MaskKeys (default false) and MaskedKeyPrefixes on the three
options classes. On, a log line that names a key keeps the prefix the key
strategy composed and masks the caller's key to its first three characters
(myapp:s:session:cos****). Only a key starting with a listed prefix is
masked, and a value that is plainly an identifier, a number or a GUID, is
left alone: identifiers are not secrets.

Each layer learns its own prefix by composing a probe key through its own
strategy, and the Redis layer keeps that in front of the cache key it
judges. Masking runs only when the line is written: the generated log
methods take a LoggedKey, which renders in ToString, so a disabled level
costs nothing and a raw key cannot reach them by accident.

AddDistributedCache masks the key in its own two messages regardless,
because IDistributedCache keys belong to the consumer and can be secrets.

Closes #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fwLrS3Sbcen8v6iRkUaFB
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 9, 2026 15:49

Copilot AI 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.

🟡 Changes recommended

Raw-key failure logs and sharded Redis key strategies can still expose sensitive cache keys.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 23/23 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment on lines +65 to +68
var composed = (string?)strategy.GetRedisKey(new CacheKey(Probe, CacheKeyCasing.Sensitive));
return composed is not null && composed.Length > Probe.Length && composed.EndsWith(Probe, StringComparison.OrdinalIgnoreCase)
? composed[..^Probe.Length]
: null;
Comment on lines +81 to +82
_keyMasking = KeyMasking.For(multiLayerCacheOptions);
_eventPublisher = new CacheEventPublisher(cacheName, _topicProvider, cacheEventFactory, logger, _keyMasking);
/// and shows only its first three characters (<c>myapp:s:session:cos****</c>). <c>AddDistributedCache</c>
/// turns it on for its own provider, with its own prefix listed.
/// </summary>
public bool MaskKeys { get => false; set => _ = value; }
@cosmin-staicu

Copy link
Copy Markdown
Member Author

Closing in favour of a redesign rather than iterating this one further.

Review here converged on a structural problem: the masking policy lives on per-tier options, so every component that logs a key needs those options threaded to it. The memory setter, the rehydration coordinator and the broadcast change token all log keys today and none of them can see the switch, which is why keys still reach logs with masking on. Patching each one spreads the same plumbing further.

The replacement is a policy seam. An IKeyMaskingPolicy answers one question, whether a key is secret, and is resolved from the container, so any component can consume it without carrying options. Rendering stays in the library and splices the masked value into whatever the key strategy composed, so it works for a prefix, a suffix or a hash tag rather than assuming the library's part comes first. Masking off is a null implementation, matching every other seam here. The distributed adapter keeps its guarantee through a per-tier policy rather than by forcing a flag on its private tiers.

Also carried over: the caller's key and the composed key are both passed to the renderer, which removes the probe that discovers the composed prefix today; identifier-shaped keys stay readable; and masking is deferred to log formatting so a disabled level costs nothing.

Branch fix/log-keys-redaction stays as the record of this attempt. New work starts from main.

Closes #129 is carried to the replacement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache keys are logged at Warning level; distributed-cache keys can be secrets (session ids)

2 participants