config: prevent raw redaction overlap from corrupting config round-trips#32174
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Under-redaction of sensitive objects due to overly permissive SecretRef detection
Description
Why this can leak secrets:
This creates a regression for sensitive fields that allow arbitrary objects. For example,
Vulnerable code: function isSecretRefShape(value: Record<string, unknown>) {
return typeof value.source === "string" && typeof value.id === "string";
}
// ...
if (hints[candidate]?.sensitive === true && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>;
if (isSecretRefShape(objectValue)) {
result[key] = redactSecretRef(objectValue, values); // only redacts id
} else {
collectSensitiveStrings(objectValue, values);
result[key] = REDACTED_SENTINEL;
}
}This is an under-redaction / sensitive data exposure risk whenever a sensitive object field can legitimately contain non-SecretRef objects with RecommendationTighten SecretRef detection to match the actual SecretRef contract, and avoid short-circuiting on lookalike objects. Recommended changes:
Example hardening: import { isSecretRef, type SecretRef } from "./types.secrets.js";
function isStrictSecretRef(value: unknown): value is SecretRef {
return isSecretRef(value);
}
// redactObjectWithLookup:
if (hints[candidate]?.sensitive === true && !Array.isArray(value)) {
if (isStrictSecretRef(value)) {
result[key] = redactSecretRef(value, values);
} else {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
}
}
// collectSensitiveStrings: optionally keep special casing only for strict SecretRef
if (isStrictSecretRef(obj)) {
if (!isEnvVarPlaceholder(obj.id)) values.push(obj.id);
return;
}This prevents lookalike objects from being partially redacted and ensures 2. 🟡 Prototype pollution via restoreRedactedValues assigning attacker-controlled keys ("proto"/"constructor"/"prototype")
Description
Because Vulnerable code: const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
result[key] = value; // key may be "__proto__"
// ...
}New/expanded exposure in this diff:
Impact depends on where the restored object flows next, but the prototype mutation is concrete and avoidable. RecommendationBlock prototype keys (at minimum A minimal fix is to reuse the project’s existing helper ( import { isBlockedObjectKey } from "./prototype-keys.js";
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
if (isBlockedObjectKey(key)) continue;
// existing logic...
result[key] = value;
}Optionally, also create accumulator objects with const result: Record<string, unknown> = Object.create(null);Apply the same guard anywhere else in this file that copies arbitrary keys into Analyzed PR: #32174 at commit Last updated on: 2026-03-02T23:51:20Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e4affcf6bb
ℹ️ 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".
| redactedRaw && | ||
| shouldFallbackToStructuredRawRedaction({ | ||
| redactedRaw, | ||
| originalConfig: snapshot.config, |
There was a problem hiding this comment.
Compare raw-fallback round-trip against parsed source
The fallback safety check restores redactedRaw against snapshot.config, but snapshot.config is normalized with runtime defaults while raw/parsed only represent user-authored fields (see src/config/io.ts where defaults are applied before snapshot.config is set). In common configs that omit defaults, this makes JSON.stringify(restored.result) !== JSON.stringify(snapshot.config) true even when text redaction was safe, so config.get falls back to JSON5.stringify(...) broadly and drops original comments/formatting. This check should be validated against the same shape as raw input (for example snapshot.parsed or pre-default resolved config) so fallback only triggers on actual overlap corruption.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced. The fallback comparator was using snapshot.config, which can include defaults not present in the parsed/raw shape, so safe text-redacted raw could be rewritten via structured fallback.
Applied on head 89a487c3ec:
- fallback comparison now restores and compares against
snapshot.parsed ?? snapshot.config. - added regression:
keeps text redaction raw when parsed/config shapes differ by defaultsto prove comments/formatting are preserved.
Verification:
pnpm -s vitest run src/config/redact-snapshot.test.ts
Greptile SummaryThis PR fixes a raw-redaction overlap bug where a sensitive string value (e.g. Key changes and observations:
Confidence Score: 3/5
Last reviewed commit: 3cc83c8 |
src/config/redact-snapshot.ts
Outdated
| if (!restored.ok) { | ||
| return true; | ||
| } | ||
| return JSON.stringify(restored.result) !== JSON.stringify(params.originalConfig); |
There was a problem hiding this comment.
JSON.stringify comparison is key-order-sensitive — may trigger spurious fallbacks
Zod reconstructs objects in schema-definition key order, not necessarily the order in which keys appear in the raw JSON5 file. After restoreRedactedValues iterates over parsed (whose keys follow the raw file's order), the resulting object's key sequence may differ from params.originalConfig (which uses Zod's schema order). A simple JSON.stringify comparison will then return true even when the values are semantically identical, causing the fallback to fire for any config file where the user has keys in a different order than the schema — silently stripping comments and original formatting.
Consider a stable comparison instead, for example sorting keys before stringifying:
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, val) =>
val && typeof val === "object" && !Array.isArray(val)
? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b)))
: val,
);
}
// …
return stableStringify(restored.result) !== stableStringify(params.originalConfig);This keeps the fail-closed safety guarantee while avoiding unnecessary fallbacks caused purely by key ordering.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 309
Comment:
**`JSON.stringify` comparison is key-order-sensitive — may trigger spurious fallbacks**
Zod reconstructs objects in schema-definition key order, not necessarily the order in which keys appear in the raw JSON5 file. After `restoreRedactedValues` iterates over `parsed` (whose keys follow the raw file's order), the resulting object's key sequence may differ from `params.originalConfig` (which uses Zod's schema order). A simple `JSON.stringify` comparison will then return `true` even when the values are semantically identical, causing the fallback to fire for any config file where the user has keys in a different order than the schema — silently stripping comments and original formatting.
Consider a stable comparison instead, for example sorting keys before stringifying:
```ts
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, val) =>
val && typeof val === "object" && !Array.isArray(val)
? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b)))
: val,
);
}
// …
return stableStringify(restored.result) !== stableStringify(params.originalConfig);
```
This keeps the fail-closed safety guarantee while avoiding unnecessary fallbacks caused purely by key ordering.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47a799b70f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (isSecretRefShape(objectValue)) { | ||
| result[key] = redactSecretRef(objectValue, values); |
There was a problem hiding this comment.
Restrict SecretRef partial redaction to real SecretRef values
The new isSecretRefShape branch at sensitive object paths redacts only id and preserves the rest of the object, but the shape check is just source + id, so any sensitive object that happens to contain those keys is treated as a SecretRef. In particular, channels.googlechat.serviceAccount is sensitive and schema-valid as an arbitrary record (src/config/zod-schema.providers-core.ts:688), so an object like {source, id, private_key} would leak private_key in both config and raw outputs instead of being fully redacted as before.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. The prior source+id shape check was too broad for sensitive object paths and could preserve unintended fields on non-SecretRef objects.
Applied on head 89a487c3ec:
- SecretRef partial-redaction now uses strict
isSecretRef(...)validation. - non-SecretRef sensitive objects are fully redacted as before.
- added regression:
fully redacts non-SecretRef sensitive objects that include source/id keys.
Verification:
pnpm -s vitest run src/config/redact-snapshot.test.ts
47a799b to
3cc83c8
Compare
|
Landed via temp rebase onto main.
Thanks @bmendonca3! |
|
Codex Review: Didn't find any major issues. You're on a roll. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
@greptile review latest commit, please. |
| function isSecretRefShape( | ||
| value: Record<string, unknown>, | ||
| ): value is Record<string, unknown> & { source: string; id: string } { | ||
| return typeof value.source === "string" && typeof value.id === "string"; | ||
| } |
There was a problem hiding this comment.
isSecretRefShape duck-typing can silently under-redact non-SecretRef objects
isSecretRefShape only requires two fields (source: string, id: string) to be present. Any object at an explicitly sensitive: true schema path that happens to carry those two string fields will be treated as a SecretRef, and only id will be collected/replaced. Any additional sensitive fields on that object (e.g. a co-located apiKey, password, or token field) will be silently skipped by both redactSecretRef and the early-return branch in collectSensitiveStrings.
The fallback safety check (shouldFallbackToStructuredRawRedaction) does not help here: when the structured-redaction path is taken via redactSecretRef, the extra fields are already dropped from values before the text replacement, so they survive into the redactedParsed/redactedConfig objects that the fallback stringifies.
A narrower guard — for example also requiring that the object has no string fields other than source, provider, and id — would limit this to genuinely SecretRef-shaped values:
function isSecretRefShape(
value: Record<string, unknown>,
): value is Record<string, unknown> & { source: string; id: string } {
const NON_SENSITIVE_SECRETREF_KEYS = new Set(["source", "provider", "id"]);
const keys = Object.keys(value);
return (
typeof value.source === "string" &&
typeof value.id === "string" &&
keys.every((k) => NON_SENSITIVE_SECRETREF_KEYS.has(k))
);
}This ensures the heuristic fires only when the object is structurally exactly a SecretRef, not any wider object that happens to contain source and id.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 27-31
Comment:
**`isSecretRefShape` duck-typing can silently under-redact non-SecretRef objects**
`isSecretRefShape` only requires two fields (`source: string`, `id: string`) to be present. Any object at an explicitly `sensitive: true` schema path that happens to carry those two string fields will be treated as a SecretRef, and only `id` will be collected/replaced. Any additional sensitive fields on that object (e.g. a co-located `apiKey`, `password`, or `token` field) will be silently skipped by both `redactSecretRef` and the early-return branch in `collectSensitiveStrings`.
The fallback safety check (`shouldFallbackToStructuredRawRedaction`) does not help here: when the structured-redaction path is taken via `redactSecretRef`, the extra fields are already dropped from `values` before the text replacement, so they survive into the `redactedParsed`/`redactedConfig` objects that the fallback stringifies.
A narrower guard — for example also requiring that the object has *no* string fields other than `source`, `provider`, and `id` — would limit this to genuinely SecretRef-shaped values:
```ts
function isSecretRefShape(
value: Record<string, unknown>,
): value is Record<string, unknown> & { source: string; id: string } {
const NON_SENSITIVE_SECRETREF_KEYS = new Set(["source", "provider", "id"]);
const keys = Object.keys(value);
return (
typeof value.source === "string" &&
typeof value.id === "string" &&
keys.every((k) => NON_SENSITIVE_SECRETREF_KEYS.has(k))
);
}
```
This ensures the heuristic fires only when the object is structurally exactly a SecretRef, not any wider object that happens to contain `source` and `id`.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Describe the problem and fix in 2–5 bullets:
config.getraw redaction can over-redact unrelated non-sensitive values when a sensitive value appears elsewhere in the JSON5 text.config.applycan fail validation (for example,gateway.modebecomes__OPENCLAW_REDACTED__).JSON5.stringify(redactedParsed)), which only redacts sensitive paths.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
config.getno longer corrupt unrelated non-sensitive fields when secret values overlap with common literals.Security Impact (required)
No)Yes)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
gateway.mode = "local"gateway.auth.password = "local"Steps
gateway.mode: "local"gateway.auth.password: "local"redactConfigSnapshot(...)with schema hints.rawand runrestoreRedactedValues(...)+ config validation.Expected
gateway.auth.passwordis redacted.gateway.mode === "local".Actual
gateway.modeinto sentinel, causing invalid config on apply.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
gateway.modewas incorrectly replaced in raw redaction before fallback logic.keeps non-sensitive raw fields intact when secret values overlap.pnpm vitest run src/config/redact-snapshot.test.ts --config vitest.unit.config.ts.pnpm check.redacts secrets in raw field via text-based redaction) still passes.redact-snapshotsuite passes.Compatibility / Migration
Yes)No)No)Failure Recovery (if this breaks)
src/config/redact-snapshot.tssrc/config/redact-snapshot.test.tsconfig.getoutput where fallback redaction is triggered.Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.JSON5.stringify) may not preserve original comments/formatting in affected raw snapshots.