Skip to content

Commit 9cdf8a1

Browse files
lukaIvanicqingsenlabclawsweeper[bot]Takhoffman
authored
Warn on plaintext secret config in doctor (#84718)
Summary: - Adds a `doctor` security warning for plaintext secret-bearing `openclaw.json` fields by reusing the secrets target registry and shared model-provider header sensitivity policy. - Reproducibility: yes. for source-level behavior: current main has plaintext secret audit coverage but no doc ... llector for those config targets, and the PR body includes live patched CLI output showing the new warning. Automerge notes: - PR branch already contained follow-up commit before automerge: Warn on plaintext secret config in doctor Validation: - ClawSweeper review passed for head 31f83aa. - Required merge gates passed before the squash merge. Prepared head SHA: 31f83aa Review: #84718 (comment) Co-authored-by: qingsenlab <qingsenlab@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent e964987 commit 9cdf8a1

6 files changed

Lines changed: 218 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- Infra/secrets: restore the fail-closed contract for `tryReadSecretFileSync` so credential loaders that pass `rejectSymlink: true` (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.
2727
- Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)
2828
- Doctor: remove unrecognized `models.providers.*.models[*].compat.thinkingFormat` values during `doctor --fix` so stale provider model config can validate after upgrade. Fixes #77803.
29+
- Doctor: warn when `openclaw.json` stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.
2930
- Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from `agents.defaults.model.primary`.
3031
- Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.
3132
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.

src/commands/doctor-security.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,99 @@ describe("noteSecurityWarnings gateway exposure", () => {
332332
);
333333
});
334334

335+
it("warns when model provider API keys are stored as plaintext in config", async () => {
336+
await noteSecurityWarnings({
337+
models: {
338+
providers: {
339+
openai: {
340+
apiKey: "sk-openai-plaintext",
341+
},
342+
},
343+
},
344+
} as unknown as OpenClawConfig);
345+
346+
const message = lastMessage();
347+
expect(message).toContain("plaintext secret-bearing config fields");
348+
expect(message).toContain("models.providers.openai.apiKey");
349+
expect(message).toContain("openclaw secrets audit --check");
350+
});
351+
352+
it("warns when sensitive model provider headers are stored as plaintext in config", async () => {
353+
await noteSecurityWarnings({
354+
models: {
355+
providers: {
356+
openai: {
357+
headers: {
358+
Authorization: "Bearer sk-header-plaintext",
359+
},
360+
},
361+
},
362+
},
363+
} as unknown as OpenClawConfig);
364+
365+
const message = lastMessage();
366+
expect(message).toContain("plaintext secret-bearing config fields");
367+
expect(message).toContain("models.providers.openai.headers.Authorization");
368+
});
369+
370+
it("does not warn when non-sensitive model provider headers are stored as plaintext in config", async () => {
371+
await noteSecurityWarnings({
372+
models: {
373+
providers: {
374+
openai: {
375+
headers: {
376+
"X-Proxy-Region": "us-west",
377+
},
378+
},
379+
},
380+
},
381+
} as unknown as OpenClawConfig);
382+
383+
const message = lastMessage();
384+
expect(message).not.toContain("plaintext secret-bearing config fields");
385+
expect(message).not.toContain("models.providers.openai.headers.X-Proxy-Region");
386+
});
387+
388+
it("keeps request headers aligned with secrets audit plaintext checks", async () => {
389+
await noteSecurityWarnings({
390+
models: {
391+
providers: {
392+
openai: {
393+
request: {
394+
headers: {
395+
"X-Proxy-Region": "us-west",
396+
},
397+
},
398+
},
399+
},
400+
},
401+
} as unknown as OpenClawConfig);
402+
403+
const message = lastMessage();
404+
expect(message).toContain("plaintext secret-bearing config fields");
405+
expect(message).toContain("models.providers.openai.request.headers.X-Proxy-Region");
406+
});
407+
408+
it("does not warn when model provider API keys are stored as SecretRefs", async () => {
409+
await noteSecurityWarnings({
410+
secrets: {
411+
providers: {
412+
default: { source: "env" },
413+
},
414+
},
415+
models: {
416+
providers: {
417+
openai: {
418+
apiKey: "${OPENAI_API_KEY}",
419+
},
420+
},
421+
},
422+
} as unknown as OpenClawConfig);
423+
424+
const message = lastMessage();
425+
expect(message).not.toContain("plaintext secret-bearing config fields");
426+
});
427+
335428
it("warns when tools.exec is broader than host exec defaults", async () => {
336429
await withExecApprovalsFile(
337430
{

src/commands/doctor-security.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import type { ChannelId } from "../channels/plugins/types.public.js";
44
import { formatCliCommand } from "../cli/command-format.js";
55
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
66
import type { AgentConfig } from "../config/types.agents.js";
7-
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
7+
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
88
import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js";
99
import { resolveGatewayAuth } from "../gateway/auth.js";
1010
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
1111
import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js";
1212
import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js";
13+
import { isLikelySensitiveModelProviderHeaderName } from "../secrets/model-provider-header-policy.js";
14+
import { hasConfiguredPlaintextSecretValue } from "../secrets/secret-value.js";
15+
import { discoverConfigSecretTargets } from "../secrets/target-registry.js";
1316
import { collectExecFilesystemPolicyDriftHits } from "../security/exec-filesystem-policy.js";
1417
import { normalizeOptionalString } from "../shared/string-coerce.js";
1518
import { note } from "../terminal/note.js";
@@ -180,6 +183,51 @@ function collectExecFilesystemPolicyWarnings(cfg: OpenClawConfig): string[] {
180183
);
181184
}
182185

186+
function collectPlaintextConfigSecretWarnings(cfg: OpenClawConfig): string[] {
187+
const plaintextPaths: string[] = [];
188+
const defaults = cfg.secrets?.defaults;
189+
190+
for (const target of discoverConfigSecretTargets(cfg)) {
191+
if (!target.entry.includeInAudit) {
192+
continue;
193+
}
194+
if (
195+
target.entry.id === "models.providers.*.headers.*" &&
196+
!isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "")
197+
) {
198+
continue;
199+
}
200+
const { ref } = resolveSecretInputRef({
201+
value: target.value,
202+
refValue: target.refValue,
203+
defaults,
204+
});
205+
if (ref) {
206+
continue;
207+
}
208+
if (!hasConfiguredPlaintextSecretValue(target.value, target.entry.expectedResolvedValue)) {
209+
continue;
210+
}
211+
plaintextPaths.push(target.path);
212+
}
213+
214+
if (plaintextPaths.length === 0) {
215+
return [];
216+
}
217+
218+
const samplePaths = plaintextPaths.slice(0, 5);
219+
const extraCount = plaintextPaths.length - samplePaths.length;
220+
const pathLine =
221+
extraCount > 0 ? `${samplePaths.join(", ")} (+${extraCount} more)` : samplePaths.join(", ");
222+
223+
return [
224+
"- WARNING: openclaw.json contains plaintext secret-bearing config fields.",
225+
` Paths: ${pathLine}`,
226+
" Agents or workspace tools that can read config files may see these API keys/tokens.",
227+
` Migrate them to SecretRefs with ${formatCliCommand("openclaw secrets configure")} or ${formatCliCommand("openclaw secrets apply")}, then verify with ${formatCliCommand("openclaw secrets audit --check")}.`,
228+
];
229+
}
230+
183231
export async function collectSecurityWarnings(
184232
cfg: OpenClawConfig,
185233
env: NodeJS.ProcessEnv = process.env,
@@ -197,6 +245,7 @@ export async function collectSecurityWarnings(
197245
warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg));
198246
warnings.push(...collectExecPolicyConflictWarnings(cfg));
199247
warnings.push(...collectExecFilesystemPolicyWarnings(cfg));
248+
warnings.push(...collectPlaintextConfigSecretWarnings(cfg));
200249
warnings.push(...collectDurableExecApprovalWarnings(cfg));
201250

202251
// ===========================================

src/secrets/audit.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,4 +710,40 @@ describe("secrets audit", () => {
710710
),
711711
).toBe(false);
712712
});
713+
714+
it("keeps request headers in openclaw config covered by plaintext audit", async () => {
715+
await writeJsonFile(fixture.configPath, {
716+
models: {
717+
providers: {
718+
openai: {
719+
baseUrl: "https://api.openai.com/v1",
720+
api: "openai-completions",
721+
apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER },
722+
request: {
723+
headers: {
724+
"X-Proxy-Region": "us-west",
725+
},
726+
},
727+
models: [{ id: "gpt-5", name: "gpt-5" }],
728+
},
729+
},
730+
},
731+
});
732+
await writeJsonFile(fixture.authStorePath, {
733+
version: 1,
734+
profiles: {},
735+
});
736+
await fs.writeFile(fixture.envPath, "", "utf8");
737+
738+
const report = await runSecretsAudit({ env: fixture.env });
739+
expect(
740+
hasFinding(
741+
report,
742+
(entry) =>
743+
entry.code === "PLAINTEXT_FOUND" &&
744+
entry.file === fixture.configPath &&
745+
entry.jsonPath === "models.providers.openai.request.headers.X-Proxy-Region",
746+
),
747+
).toBe(true);
748+
});
713749
});

src/secrets/audit.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
1010
import { coerceSecretRef } from "../config/types.secrets.js";
1111
import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js";
1212
import { formatErrorMessage } from "../infra/errors.js";
13-
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
1413
import { resolveConfigDir, resolveUserPath } from "../utils.js";
1514
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
1615
import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js";
1716
import { createSecretsConfigIO } from "./config-io.js";
1817
import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js";
18+
import { isLikelySensitiveModelProviderHeaderName } from "./model-provider-header-policy.js";
1919
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
2020
import { secretRefKey } from "./ref-contract.js";
2121
import {
@@ -105,41 +105,6 @@ type AuditCollector = {
105105

106106
const REF_RESOLVE_FALLBACK_CONCURRENCY = 8;
107107
const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024;
108-
const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([
109-
"authorization",
110-
"proxy-authorization",
111-
"x-api-key",
112-
"api-key",
113-
"apikey",
114-
"x-auth-token",
115-
"auth-token",
116-
"x-access-token",
117-
"access-token",
118-
"x-secret-key",
119-
"secret-key",
120-
]);
121-
const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [
122-
"api-key",
123-
"apikey",
124-
"token",
125-
"secret",
126-
"password",
127-
"credential",
128-
];
129-
130-
function isLikelySensitiveModelProviderHeaderName(value: string): boolean {
131-
const normalized = normalizeLowercaseStringOrEmpty(value);
132-
if (!normalized) {
133-
return false;
134-
}
135-
if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) {
136-
return true;
137-
}
138-
return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) =>
139-
normalized.includes(fragment),
140-
);
141-
}
142-
143108
function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void {
144109
collector.findings.push(finding);
145110
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
2+
3+
const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([
4+
"authorization",
5+
"proxy-authorization",
6+
"x-api-key",
7+
"api-key",
8+
"apikey",
9+
"x-auth-token",
10+
"auth-token",
11+
"x-access-token",
12+
"access-token",
13+
"x-secret-key",
14+
"secret-key",
15+
]);
16+
17+
const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [
18+
"api-key",
19+
"apikey",
20+
"token",
21+
"secret",
22+
"password",
23+
"credential",
24+
];
25+
26+
export function isLikelySensitiveModelProviderHeaderName(value: string): boolean {
27+
const normalized = normalizeLowercaseStringOrEmpty(value);
28+
if (!normalized) {
29+
return false;
30+
}
31+
if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) {
32+
return true;
33+
}
34+
return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) =>
35+
normalized.includes(fragment),
36+
);
37+
}

0 commit comments

Comments
 (0)