Skip to content

fix(configmap-loader): redact secret values from validation errors#891

Open
vvnpn-nv wants to merge 4 commits intomainfrom
vpan/redact-secrets-in-validation-errors
Open

fix(configmap-loader): redact secret values from validation errors#891
vvnpn-nv wants to merge 4 commits intomainfrom
vpan/redact-secrets-in-validation-errors

Conversation

@vvnpn-nv
Copy link
Copy Markdown
Contributor

@vvnpn-nv vvnpn-nv commented Apr 22, 2026

Problem

Pydantic's default ValidationError echoes input_value=<repr> into the error message. When a rejected field holds a resolved secret (e.g. an access_key merged from a mounted K8s Secret), the plaintext lands in the loader's ERROR log line and in the K8s Event the loader writes to the ConfigMap.

Fix

Never echo submitted values. Output: <path>: <reason> (input_type=<type>).

Before:

workflow.workflow_data.credential.DefaultDataCredential.access_key:
  Extra inputs are not permitted [input_value='abcdefg...', input_type=str]

After:

workflow.workflow_data.credential.DefaultDataCredential.access_key:
  Extra inputs are not permitted (input_type=str)

Earlier revisions tried a curated sensitive-name allowlist — review surfaced that it kept missing real OSMO fields (postgres_password, etc.). The final version has zero hardcoding: no allowlist, no introspection, no per-field work when new secret fields are added.

What operators lose

For type typos like max_num_tasks: 'not-a-number', the echoed 'not-a-number' is gone. Operator sees the path + input_type=str and grep their values file. Checked-in Helm values are the source of truth.

Test plan

  • bazel test //src/service/core/config/... — pass (4 new tests)
  • pylint + mypy green

Regression test test_real_models_never_leak_any_value reproduces the staging scenario and asserts no submitted value appears in the output.

🤖 Generated with Claude Code

Issue - None

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Configuration validation errors now provide enhanced feedback with complete field path information and input data types while preventing sensitive credential data from appearing in error messages.
  • Tests

    • Comprehensive test coverage added for validation error formatting to verify proper error reporting and credential protection.

Pydantic's default ValidationError stringifier echoes input_value
into the error message for every rejected field. When a resolved
secret (access_key, slack_token, etc.) is rejected — e.g. because an
adjacent field like `_comment` triggers a discriminator mismatch in a
strict union model — the plaintext secret ends up in the loader's
ERROR log line AND in the K8s Event that the loader writes to the
ConfigMap. Both channels are visible to anyone with read access.

Discovered during the staging ConfigMap rollout: three stray
`_comment:` fields under workflow_{data,log,app}.credential caused
the discriminator to fail, and every subsequent rejected field in the
same credential — including access_key — was echoed into the logs
with input_value=<plaintext>. The Swift access_key was exposed.

Fix:
- Catch `pydantic.ValidationError` specifically in _validate_configs
- Walk the structured .errors() list; for leaf field names in a
  curated sensitive-name set (access_key, password, slack_token,
  smtp_password, api_key, secret_key, auth, value), substitute
  `<redacted>` for the input value
- Non-sensitive fields keep their input values visible so operators
  can debug typos in _comment, endpoint, region, etc.

Fields deliberately NOT redacted: secretName, secretKey, access_key_id
(these are references/metadata, not the secret content).

5 new unit tests; the regression test reproduces the staging bug end
to end and asserts the plaintext never appears in the returned error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vvnpn-nv vvnpn-nv requested a review from a team as a code owner April 22, 2026 23:17
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

Formats Pydantic ValidationErrors when validating config singletons by iterating error.errors(), building semicolon-joined messages with dotted field locations and error reasons, and appending the rejected input’s Python type name when present — explicitly omitting the rejected value itself.

Changes

Cohort / File(s) Summary
Validation Formatting
src/service/core/config/configmap_loader.py
Adds explicit handling for pydantic.ValidationError in _validate_configs() and a new _format_validation_error() helper that iterates error.errors(), formats dotted loc, msg, and (when available) the rejected value's Python type name; collects per-config formatted error strings.
Validation Formatting Tests
src/service/core/config/tests/test_configmap_loader_unit.py
Adds TestValidationErrorFormatting tests asserting formatted errors include field paths and input type names, do not include submitted values, and that _validate_configs() aggregates messages without leaking credential-like input strings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hop through errors, tidy and small,
I name the field and reason — but not the all.
Types stand in place where secrets once lay,
Silent values kept safe on my way.
A careful nibble, then off I scrawl.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(configmap-loader): redact secret values from validation errors' directly and clearly describes the main change: preventing secret values from appearing in validation error messages.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vpan/redact-secrets-in-validation-errors

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/service/core/config/configmap_loader.py`:
- Around line 284-290: The current branch that builds input_repr inspects only
loc_parts against _SENSITIVE_FIELD_NAMES and then calls repr(input_value), which
can leak nested secrets; add a helper (e.g., sanitize_recursive or
_sanitize_nested_input) that walks dicts/lists/tuples and replaces any value
whose key (case-insensitive) matches _SENSITIVE_FIELD_NAMES with _REDACTED, then
call this sanitizer on input_value before calling repr; update the logic around
loc_parts/input_value in the block that computes input_repr so that if loc_parts
contains a sensitive key we still redact, otherwise we compute sanitized =
sanitize_recursive(input_value) and set input_repr = repr(sanitized) (preserve
existing None handling).

In `@src/service/core/config/tests/test_configmap_loader_unit.py`:
- Line 359: Replace the mutable dict assignment used for Pydantic model
configuration—change the class attribute model_config = {'extra': 'forbid'} on
the helper model to use pydantic's ConfigDict (e.g. model_config =
ConfigDict(extra='forbid')) and add the import from pydantic (ConfigDict) so the
class-level config is immutable and RUF012 is satisfied.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 14e4d565-e7b8-424b-b08f-5a5e3ab46729

📥 Commits

Reviewing files that changed from the base of the PR and between 60d664b and a5bcb4a.

📒 Files selected for processing (2)
  • src/service/core/config/configmap_loader.py
  • src/service/core/config/tests/test_configmap_loader_unit.py

Comment thread src/service/core/config/configmap_loader.py Outdated
Comment thread src/service/core/config/tests/test_configmap_loader_unit.py
Two security issues + one design critique from the review:

1. Broaden name matching (substring + whitelist)
   The previous hardcoded-exact-name set missed real OSMO field
   names: postgres_password, redis_password, cluster_api_key,
   default_admin_password, private_key, oidc_secret. Switch to
   substring match on password/token/secret/access_key/private_key/
   api_key with an explicit whitelist for metadata fields
   (access_key_id, secretName, secretKey, secret_file, mek_file,
   currentMek). New test `test_substring_matches_real_field_names`
   asserts every previously-leaking name is now redacted; new
   `test_whitelisted_metadata_fields_not_redacted` asserts the
   whitelist still shows values for debuggability.

2. Defensive recursive redaction of dict/list inputs
   When Pydantic surfaces a parent dict as the error input (can
   happen with union discriminator / model_validator failures),
   repr(dict) would leak any secret-valued key inside. New
   `_redact_nested()` walks dicts + lists and replaces sensitive
   values in place before repr. New `test_redact_nested_walks_deeply`
   covers nested dicts and lists of dicts.

3. Drop over-aggressive entries
   Removed 'value' (too common, over-redacted debug info) and
   narrowed 'auth' so it only matches via substring ('auth_' etc.),
   not as a standalone top-level key name.

All 64 tests pass; pylint + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/service/core/config/tests/test_configmap_loader_unit.py (1)

358-360: ⚠️ Potential issue | 🟡 Minor

Use ConfigDict for model_config to avoid mutable class config.

model_config = {'extra': 'forbid'} is a mutable class attribute and triggers Ruff RUF012. Prefer Pydantic ConfigDict(...) here.

🧩 Proposed fix
         class FakeModel(configmap_loader.pydantic.BaseModel):
-            model_config = {'extra': 'forbid'}
+            model_config = configmap_loader.pydantic.ConfigDict(extra='forbid')
             name: str = ''
#!/bin/bash
# Verify mutable model_config usage and confirm ConfigDict-based fix target.
rg -nP 'model_config\s*=\s*\{' src/service/core/config/tests/test_configmap_loader_unit.py
rg -nP 'model_config\s*=\s*.*ConfigDict\s*\(' src/service/core/config/tests/test_configmap_loader_unit.py
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/service/core/config/tests/test_configmap_loader_unit.py` around lines 358
- 360, Replace the mutable dict assigned to the Pydantic model config in
FakeModel with a ConfigDict: update the class FakeModel (subclassing
configmap_loader.pydantic.BaseModel) so its model_config uses pydantic's
ConfigDict(...) rather than a plain dict (e.g., model_config =
ConfigDict(extra='forbid')), and add the necessary import for ConfigDict from
pydantic so Ruff RUF012 is resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/service/core/config/tests/test_configmap_loader_unit.py`:
- Around line 358-360: Replace the mutable dict assigned to the Pydantic model
config in FakeModel with a ConfigDict: update the class FakeModel (subclassing
configmap_loader.pydantic.BaseModel) so its model_config uses pydantic's
ConfigDict(...) rather than a plain dict (e.g., model_config =
ConfigDict(extra='forbid')), and add the necessary import for ConfigDict from
pydantic so Ruff RUF012 is resolved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5763afe9-18a2-4ff5-9e3d-af40092e1f8b

📥 Commits

Reviewing files that changed from the base of the PR and between a5bcb4a and 13bbfb2.

📒 Files selected for processing (2)
  • src/service/core/config/configmap_loader.py
  • src/service/core/config/tests/test_configmap_loader_unit.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/service/core/config/configmap_loader.py

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

❌ Patch coverage is 85.71429% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.45%. Comparing base (60d664b) to head (29812cf).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/service/core/config/configmap_loader.py 85.71% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #891      +/-   ##
==========================================
+ Coverage   42.32%   42.45%   +0.13%     
==========================================
  Files         211      211              
  Lines       27217    26831     -386     
  Branches     4193     4195       +2     
==========================================
- Hits        11520    11392     -128     
+ Misses      15095    14834     -261     
- Partials      602      605       +3     
Flag Coverage Δ
backend 43.32% <85.71%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/service/core/config/configmap_loader.py 79.93% <85.71%> (+0.26%) ⬆️

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Addresses coderabbitai review comment on the curated sensitive-name
set being fragile (postgres_password, redis_password, cluster_api_key
etc. were all leaking because they weren't in the list).

Replace the entire hardcoding machinery with a simple policy: never
echo `input_value` from Pydantic errors. The output now contains only
the field path, Pydantic's reason, and the Python type of the
submitted value (e.g. `input_type=str`). That's enough to diagnose
"I put a string where an int was expected" without ever touching the
content.

Before (leaky, required maintaining substring + whitelist lists):
  workflow.workflow_data.credential.DefaultDataCredential.access_key:
    Extra inputs are not permitted [type=extra_forbidden,
    input_value='3b197f00166babf3284c9de6ea77ebda', input_type=str]

After (zero hardcoding, no value ever echoed):
  workflow.workflow_data.credential.DefaultDataCredential.access_key:
    Extra inputs are not permitted (input_type=str)

The checked-in Helm values / ConfigMap are the source of truth for
what was submitted; the error message doesn't need to echo it back.
This matches the K8s admission-webhook error style and eliminates
the whole class of "did we remember every sensitive field name?"
bugs. Adding a new secret field is now zero-touch.

Removed: _SENSITIVE_NAME_SUBSTRINGS, _NON_SENSITIVE_FIELD_NAMES,
_is_sensitive_name(), _redact_nested(), and all associated tests.

Net: -108 lines from the prior revision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/service/core/config/tests/test_configmap_loader_unit.py (1)

360-362: ⚠️ Potential issue | 🟡 Minor

Use ConfigDict for model_config to avoid mutable class-attribute config.

model_config = {'extra': 'forbid'} still triggers RUF012 and remains mutable on the class.

🧹 Proposed fix
         class FakeModel(configmap_loader.pydantic.BaseModel):
-            model_config = {'extra': 'forbid'}
+            model_config = configmap_loader.pydantic.ConfigDict(extra='forbid')
             name: str = ''
#!/bin/bash
set -euo pipefail

# Verify this mutable model_config pattern still exists
rg -nP --type=py "model_config\s*=\s*\{"

# Verify ConfigDict is available/used in the repo
rg -nP --type=py "ConfigDict\s*\(" src/

# Verify pydantic dependency declaration in manifests
fd -i "pyproject.toml|requirements*.txt|setup.py|poetry.lock|uv.lock" -x rg -n "pydantic" {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/service/core/config/tests/test_configmap_loader_unit.py` around lines 360
- 362, The test defines FakeModel inheriting from
configmap_loader.pydantic.BaseModel and sets a mutable class attribute
model_config = {'extra': 'forbid'} which triggers RUF012; replace that mutable
dict with a pydantic ConfigDict instance (e.g., set model_config =
configmap_loader.pydantic.ConfigDict(extra='forbid') or import ConfigDict and
use ConfigDict(extra='forbid')) so the model configuration is immutable and
compliant; update the FakeModel declaration accordingly (reference symbols:
FakeModel, configmap_loader.pydantic.BaseModel, model_config, ConfigDict).
🧹 Nitpick comments (1)
src/service/core/config/configmap_loader.py (1)

266-274: Prefer descriptive names in _format_validation_error loop.

Use full variable names instead of abbreviations (err, loc, msg) to match repository Python naming guidance.

♻️ Suggested refactor
-    for err in error.errors():
-        loc_parts = tuple(str(p) for p in err.get('loc', ()))
-        loc = '.'.join(loc_parts) if loc_parts else '<root>'
-        msg = err.get('msg', '')
-        if 'input' in err:
-            input_type = type(err['input']).__name__
-            parts.append(f'{loc}: {msg} (input_type={input_type})')
+    for validation_error in error.errors():
+        location_parts = tuple(
+            str(location_part) for location_part in validation_error.get('loc', ())
+        )
+        location = '.'.join(location_parts) if location_parts else '<root>'
+        message = validation_error.get('msg', '')
+        if 'input' in validation_error:
+            input_type = type(validation_error['input']).__name__
+            parts.append(f'{location}: {message} (input_type={input_type})')
         else:
-            parts.append(f'{loc}: {msg}')
+            parts.append(f'{location}: {message}')

As per coding guidelines: "Do not use abbreviations in Python variable names unless they are well-understood ... Use full, descriptive names."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/service/core/config/configmap_loader.py` around lines 266 - 274, In
_format_validation_error, replace short variable names in the loop with
descriptive ones: rename err -> error, loc_parts -> location_parts, loc ->
location, msg -> message, and input_type -> input_value_type (or similar) and
update all references inside that for block accordingly; keep the same logic
(build location_parts from error.get('loc', ()), join into location or '<root>',
extract message from error.get('msg', ''), detect 'input' and derive
input_value_type = type(error['input']).__name__, and append formatted strings
to parts) so behavior is unchanged but names follow the repository's
no-abbreviations guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/service/core/config/tests/test_configmap_loader_unit.py`:
- Around line 360-362: The test defines FakeModel inheriting from
configmap_loader.pydantic.BaseModel and sets a mutable class attribute
model_config = {'extra': 'forbid'} which triggers RUF012; replace that mutable
dict with a pydantic ConfigDict instance (e.g., set model_config =
configmap_loader.pydantic.ConfigDict(extra='forbid') or import ConfigDict and
use ConfigDict(extra='forbid')) so the model configuration is immutable and
compliant; update the FakeModel declaration accordingly (reference symbols:
FakeModel, configmap_loader.pydantic.BaseModel, model_config, ConfigDict).

---

Nitpick comments:
In `@src/service/core/config/configmap_loader.py`:
- Around line 266-274: In _format_validation_error, replace short variable names
in the loop with descriptive ones: rename err -> error, loc_parts ->
location_parts, loc -> location, msg -> message, and input_type ->
input_value_type (or similar) and update all references inside that for block
accordingly; keep the same logic (build location_parts from error.get('loc',
()), join into location or '<root>', extract message from error.get('msg', ''),
detect 'input' and derive input_value_type = type(error['input']).__name__, and
append formatted strings to parts) so behavior is unchanged but names follow the
repository's no-abbreviations guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6b0cf14b-95cb-471e-be55-887482b2a335

📥 Commits

Reviewing files that changed from the base of the PR and between 13bbfb2 and 7100a0f.

📒 Files selected for processing (2)
  • src/service/core/config/configmap_loader.py
  • src/service/core/config/tests/test_configmap_loader_unit.py

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

📖 Docs preview: https://d3in15bfzp49i0.cloudfront.net/891/index.html

Rationale fits in two lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant