diff --git a/CHANGELOG.md b/CHANGELOG.md index 749a0a62d8c..213e174add3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Channels/stream previews: contain rejected background draft-stream flushes so preview send failures do not surface as fatal unhandled rejections. Fixes #82712. (#82713) Thanks @coygeek. - Providers/OpenAI Codex: include base `gpt-5.5` and `gpt-5.4` reasoning metadata in the bundled Codex catalog so `/think xhigh` remains available for those models. Fixes #82744. - Providers/MiniMax: declare CN endpoint auth aliases in the plugin manifest so `minimax-cn` and `minimax-portal-cn` reuse the correct base auth profiles instead of falling back to unrelated models after 401s. Fixes #63823. Thanks @kamusis. +- Secrets/audit: treat `$VAR` auth-profile values as env SecretRefs and stop reporting env-ref credentials as plaintext, including mixed `keyRef` plus env-ref profile states. Fixes #53998. Thanks @schirloc and @artwalker. - Agents/model fallback: suppress fallback notices when the active OpenAI Codex runtime reports the same canonical OpenAI model. - Agents/music generation: remove model-controlled request timeouts, default internal provider requests to five minutes, and keep configured timeouts at a 120-second floor. - Agents/media generation: stop logging delivered failure summaries as missing message-tool delivery when no generated media was expected. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 0d2145fe125..9a7347ac148 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -88,6 +88,13 @@ Use one object shape everywhere: { source: "env", provider: "default", id: "OPENAI_API_KEY" } ``` + Supported SecretInput fields also accept exact string shorthands: + + ```json5 + "${OPENAI_API_KEY}" + "$OPENAI_API_KEY" + ``` + Validation: - `provider` must match `^[a-z][a-z0-9_-]{0,63}$` diff --git a/src/config/types.secrets.test.ts b/src/config/types.secrets.test.ts new file mode 100644 index 00000000000..0ab5430f439 --- /dev/null +++ b/src/config/types.secrets.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { parseEnvTemplateSecretRef } from "./types.secrets.js"; + +describe("parseEnvTemplateSecretRef", () => { + it("parses ${VAR} template syntax", () => { + expect(parseEnvTemplateSecretRef("${OPENAI_API_KEY}")).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }); + }); + + it("parses $VAR shorthand syntax", () => { + expect(parseEnvTemplateSecretRef("$OPENAI_API_KEY")).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }); + }); + + it("trims whitespace before matching", () => { + expect(parseEnvTemplateSecretRef(" $FOO_BAR ")).toEqual({ + source: "env", + provider: "default", + id: "FOO_BAR", + }); + }); + + it("uses the provided provider alias", () => { + expect(parseEnvTemplateSecretRef("$MY_KEY", "custom")).toEqual({ + source: "env", + provider: "custom", + id: "MY_KEY", + }); + }); + + it("rejects lowercase shorthand", () => { + expect(parseEnvTemplateSecretRef("$openai_api_key")).toBeNull(); + }); + + it("rejects partial shell-style strings", () => { + expect(parseEnvTemplateSecretRef("prefix-$OPENAI_API_KEY")).toBeNull(); + }); +}); diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 2faaecb4b79..7d9126f21c4 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -21,6 +21,7 @@ export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; export const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret export const LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX = "__env__:"; // pragma: allowlist secret const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; +const ENV_SECRET_SHORTHAND_RE = /^\$([A-Z][A-Z0-9_]{0,127})$/; export type SecretInputStringResolutionMode = "strict" | "inspect"; export type SecretInputStringResolution = | { status: "available"; value: string; ref: null } @@ -73,7 +74,8 @@ export function parseEnvTemplateSecretRef( if (typeof value !== "string") { return null; } - const match = ENV_SECRET_TEMPLATE_RE.exec(value.trim()); + const trimmed = value.trim(); + const match = ENV_SECRET_TEMPLATE_RE.exec(trimmed) ?? ENV_SECRET_SHORTHAND_RE.exec(trimmed); if (!match) { return null; } diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index d77d74166cd..a2a2a92e087 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -582,6 +582,101 @@ describe("secrets audit", () => { expect(report.filesScanned).toContain(externalModelsPath); }); + it("does not flag $VAR shorthand env refs in auth profiles as plaintext", async () => { + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "$OPENAI_API_KEY", // pragma: allowlist secret + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "PLAINTEXT_FOUND" && entry.file === fixture.authStorePath, + ), + ).toBe(false); + }); + + it("does not flag ${VAR} env refs in auth profiles as plaintext", async () => { + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "${OPENAI_API_KEY}", // pragma: allowlist secret + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "PLAINTEXT_FOUND" && entry.file === fixture.authStorePath, + ), + ).toBe(false); + }); + + it("still flags auth profile plaintext when an explicit ref is also configured", async () => { + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-leftover-plaintext", // pragma: allowlist secret + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.authStorePath && + entry.jsonPath === "profiles.openai:default.key", + ), + ).toBe(true); + }); + + it.each(["$OPENAI_API_KEY", "${OPENAI_API_KEY}"])( + "does not flag %s auth profile env refs when an explicit ref is also configured", + async (value) => { + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: value, + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.authStorePath && + entry.jsonPath === "profiles.openai:default.key", + ), + ).toBe(false); + }, + ); + it("does not flag non-sensitive routing headers in openclaw config", async () => { await writeJsonFile(fixture.configPath, { models: { diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index bbe1db0d4ab..09170c961f6 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -291,6 +291,7 @@ function collectAuthStoreSecrets(params: { refValue: entry.refValue, defaults: params.defaults, }); + const authoredValueRef = coerceSecretRef(entry.value, params.defaults); if (ref) { params.collector.refAssignments.push({ file: params.authStorePath, @@ -301,6 +302,9 @@ function collectAuthStoreSecrets(params: { }); trackAuthProviderState(params.collector, entry.provider, entry.kind); } + if (authoredValueRef) { + continue; + } if (isNonEmptyString(entry.value)) { addFinding(params.collector, { code: "PLAINTEXT_FOUND",