mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 20:44:31 +00:00
fix(secrets): harden apply and audit plan handling
This commit is contained in:
committed by
Peter Steinberger
parent
ea1ccf4896
commit
d879c7c641
@@ -140,7 +140,7 @@ const SecretsExecProviderSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const SecretsProviderSchema = z.discriminatedUnion("source", [
|
export const SecretProviderSchema = z.discriminatedUnion("source", [
|
||||||
SecretsEnvProviderSchema,
|
SecretsEnvProviderSchema,
|
||||||
SecretsFileProviderSchema,
|
SecretsFileProviderSchema,
|
||||||
SecretsExecProviderSchema,
|
SecretsExecProviderSchema,
|
||||||
@@ -152,7 +152,7 @@ export const SecretsConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
// Keep this as a record so users can define multiple providers per source.
|
// Keep this as a record so users can define multiple providers per source.
|
||||||
})
|
})
|
||||||
.catchall(SecretsProviderSchema)
|
.catchall(SecretProviderSchema)
|
||||||
.optional(),
|
.optional(),
|
||||||
defaults: z
|
defaults: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -171,12 +171,75 @@ describe("secrets apply", () => {
|
|||||||
const first = await runSecretsApply({ plan, env, write: true });
|
const first = await runSecretsApply({ plan, env, write: true });
|
||||||
expect(first.changed).toBe(true);
|
expect(first.changed).toBe(true);
|
||||||
|
|
||||||
|
// Second apply should be a true no-op and avoid file writes entirely.
|
||||||
|
await fs.chmod(configPath, 0o400);
|
||||||
|
await fs.chmod(authStorePath, 0o400);
|
||||||
|
|
||||||
const second = await runSecretsApply({ plan, env, write: true });
|
const second = await runSecretsApply({ plan, env, write: true });
|
||||||
expect(second.mode).toBe("write");
|
expect(second.mode).toBe("write");
|
||||||
expect(second.changed).toBe(false);
|
expect(second.changed).toBe(false);
|
||||||
expect(second.changedFiles).toEqual([]);
|
expect(second.changedFiles).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies targets safely when map keys contain dots", async () => {
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"openai.dev": {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "sk-openai-plaintext",
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan: SecretsApplyPlan = {
|
||||||
|
version: 1,
|
||||||
|
protocolVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
generatedBy: "manual",
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
type: "models.providers.apiKey",
|
||||||
|
path: "models.providers.openai.dev.apiKey",
|
||||||
|
pathSegments: ["models", "providers", "openai.dev", "apiKey"],
|
||||||
|
providerId: "openai.dev",
|
||||||
|
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
scrubEnv: false,
|
||||||
|
scrubAuthProfilesForProviderTargets: false,
|
||||||
|
scrubLegacyAuthJson: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runSecretsApply({ plan, env, write: true });
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
|
||||||
|
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||||
|
models?: {
|
||||||
|
providers?: Record<string, { apiKey?: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(nextConfig.models?.providers?.["openai.dev"]?.apiKey).toEqual({
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "OPENAI_API_KEY",
|
||||||
|
});
|
||||||
|
expect(nextConfig.models?.providers?.openai).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies provider upserts and deletes from plan", async () => {
|
it("applies provider upserts and deletes from plan", async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
configPath,
|
configPath,
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import type { ConfigWriteOptions } from "../config/io.js";
|
|||||||
import type { SecretProviderConfig } from "../config/types.secrets.js";
|
import type { SecretProviderConfig } from "../config/types.secrets.js";
|
||||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||||
import { createSecretsConfigIO } from "./config-io.js";
|
import { createSecretsConfigIO } from "./config-io.js";
|
||||||
import { type SecretsApplyPlan, normalizeSecretsPlanOptions } from "./plan.js";
|
import {
|
||||||
|
type SecretsApplyPlan,
|
||||||
|
type SecretsPlanTarget,
|
||||||
|
normalizeSecretsPlanOptions,
|
||||||
|
} from "./plan.js";
|
||||||
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
|
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
|
||||||
import { resolveSecretRefValue } from "./resolve.js";
|
import { resolveSecretRefValue } from "./resolve.js";
|
||||||
import { prepareSecretsRuntimeSnapshot } from "./runtime.js";
|
import { prepareSecretsRuntimeSnapshot } from "./runtime.js";
|
||||||
@@ -52,8 +56,10 @@ function parseDotPath(pathname: string): string[] {
|
|||||||
return pathname.split(".").filter(Boolean);
|
return pathname.split(".").filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getByDotPath(root: unknown, pathLabel: string): unknown {
|
function getByPathSegments(root: unknown, segments: string[]): unknown {
|
||||||
const segments = parseDotPath(pathLabel);
|
if (segments.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let cursor: unknown = root;
|
let cursor: unknown = root;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (!isRecord(cursor)) {
|
if (!isRecord(cursor)) {
|
||||||
@@ -64,8 +70,7 @@ function getByDotPath(root: unknown, pathLabel: string): unknown {
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setByDotPath(root: OpenClawConfig, pathLabel: string, value: unknown): boolean {
|
function setByPathSegments(root: OpenClawConfig, segments: string[], value: unknown): boolean {
|
||||||
const segments = parseDotPath(pathLabel);
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
throw new Error("Target path is empty.");
|
throw new Error("Target path is empty.");
|
||||||
}
|
}
|
||||||
@@ -88,8 +93,7 @@ function setByDotPath(root: OpenClawConfig, pathLabel: string, value: unknown):
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteByDotPath(root: OpenClawConfig, pathLabel: string): boolean {
|
function deleteByPathSegments(root: OpenClawConfig, segments: string[]): boolean {
|
||||||
const segments = parseDotPath(pathLabel);
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,18 @@ function deleteByDotPath(root: OpenClawConfig, pathLabel: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTargetPathSegments(target: SecretsPlanTarget): string[] {
|
||||||
|
const explicit = target.pathSegments;
|
||||||
|
if (
|
||||||
|
Array.isArray(explicit) &&
|
||||||
|
explicit.length > 0 &&
|
||||||
|
explicit.every((segment) => typeof segment === "string" && segment.trim().length > 0)
|
||||||
|
) {
|
||||||
|
return [...explicit];
|
||||||
|
}
|
||||||
|
return parseDotPath(target.path);
|
||||||
|
}
|
||||||
|
|
||||||
function parseEnvValue(raw: string): string {
|
function parseEnvValue(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (
|
if (
|
||||||
@@ -203,11 +219,13 @@ function collectAuthJsonPaths(stateDir: string): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGoogleChatRefPath(pathLabel: string): string {
|
function resolveGoogleChatRefPathSegments(pathSegments: string[]): string[] {
|
||||||
if (pathLabel.endsWith(".serviceAccount")) {
|
if (pathSegments.at(-1) === "serviceAccount") {
|
||||||
return `${pathLabel}Ref`;
|
return [...pathSegments.slice(0, -1), "serviceAccountRef"];
|
||||||
}
|
}
|
||||||
throw new Error(`Google Chat target path must end with ".serviceAccount": ${pathLabel}`);
|
throw new Error(
|
||||||
|
`Google Chat target path must end with "serviceAccount": ${pathSegments.join(".")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProviderPlanMutations(params: {
|
function applyProviderPlanMutations(params: {
|
||||||
@@ -280,25 +298,26 @@ async function projectPlanState(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const target of params.plan.targets) {
|
for (const target of params.plan.targets) {
|
||||||
|
const targetPathSegments = resolveTargetPathSegments(target);
|
||||||
if (target.type === "channels.googlechat.serviceAccount") {
|
if (target.type === "channels.googlechat.serviceAccount") {
|
||||||
const previous = getByDotPath(nextConfig, target.path);
|
const previous = getByPathSegments(nextConfig, targetPathSegments);
|
||||||
if (isNonEmptyString(previous)) {
|
if (isNonEmptyString(previous)) {
|
||||||
scrubbedValues.add(previous.trim());
|
scrubbedValues.add(previous.trim());
|
||||||
}
|
}
|
||||||
const refPath = resolveGoogleChatRefPath(target.path);
|
const refPathSegments = resolveGoogleChatRefPathSegments(targetPathSegments);
|
||||||
const wroteRef = setByDotPath(nextConfig, refPath, target.ref);
|
const wroteRef = setByPathSegments(nextConfig, refPathSegments, target.ref);
|
||||||
const deletedLegacy = deleteByDotPath(nextConfig, target.path);
|
const deletedLegacy = deleteByPathSegments(nextConfig, targetPathSegments);
|
||||||
if (wroteRef || deletedLegacy) {
|
if (wroteRef || deletedLegacy) {
|
||||||
changedFiles.add(configPath);
|
changedFiles.add(configPath);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = getByDotPath(nextConfig, target.path);
|
const previous = getByPathSegments(nextConfig, targetPathSegments);
|
||||||
if (isNonEmptyString(previous)) {
|
if (isNonEmptyString(previous)) {
|
||||||
scrubbedValues.add(previous.trim());
|
scrubbedValues.add(previous.trim());
|
||||||
}
|
}
|
||||||
const wroteRef = setByDotPath(nextConfig, target.path, target.ref);
|
const wroteRef = setByPathSegments(nextConfig, targetPathSegments, target.ref);
|
||||||
if (wroteRef) {
|
if (wroteRef) {
|
||||||
changedFiles.add(configPath);
|
changedFiles.add(configPath);
|
||||||
}
|
}
|
||||||
@@ -510,6 +529,15 @@ export async function runSecretsApply(params: {
|
|||||||
warnings: projected.warnings,
|
warnings: projected.warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (changedFiles.length === 0) {
|
||||||
|
return {
|
||||||
|
mode: "write",
|
||||||
|
changed: false,
|
||||||
|
changedFiles: [],
|
||||||
|
warningCount: projected.warnings.length,
|
||||||
|
warnings: projected.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const io = createSecretsConfigIO({ env });
|
const io = createSecretsConfigIO({ env });
|
||||||
const snapshots = new Map<string, FileSnapshot>();
|
const snapshots = new Map<string, FileSnapshot>();
|
||||||
|
|||||||
@@ -105,4 +105,77 @@ describe("secrets audit", () => {
|
|||||||
await expect(fs.stat(authJsonPath)).resolves.toBeTruthy();
|
await expect(fs.stat(authJsonPath)).resolves.toBeTruthy();
|
||||||
await expect(fs.stat(authStorePath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(fs.stat(authStorePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports malformed sidecar JSON as findings instead of crashing", async () => {
|
||||||
|
await fs.writeFile(authStorePath, "{invalid-json", "utf8");
|
||||||
|
await fs.writeFile(authJsonPath, "{invalid-json", "utf8");
|
||||||
|
|
||||||
|
const report = await runSecretsAudit({ env });
|
||||||
|
expect(report.findings.some((entry) => entry.file === authStorePath)).toBe(true);
|
||||||
|
expect(report.findings.some((entry) => entry.file === authJsonPath)).toBe(true);
|
||||||
|
expect(report.findings.some((entry) => entry.code === "REF_UNRESOLVED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batches ref resolution per provider during audit", async () => {
|
||||||
|
const execLogPath = path.join(rootDir, "exec-calls.log");
|
||||||
|
const execScriptPath = path.join(rootDir, "resolver.mjs");
|
||||||
|
await fs.writeFile(
|
||||||
|
execScriptPath,
|
||||||
|
[
|
||||||
|
"#!/usr/bin/env node",
|
||||||
|
"import fs from 'node:fs';",
|
||||||
|
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));",
|
||||||
|
`fs.appendFileSync(${JSON.stringify(execLogPath)}, 'x\\n');`,
|
||||||
|
"const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `value:${id}`]));",
|
||||||
|
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
|
||||||
|
].join("\n"),
|
||||||
|
{ encoding: "utf8", mode: 0o700 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
|
source: "exec",
|
||||||
|
command: execScriptPath,
|
||||||
|
jsonOnly: true,
|
||||||
|
passEnv: ["PATH"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" },
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
},
|
||||||
|
moonshot: {
|
||||||
|
baseUrl: "https://api.moonshot.cn/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: { source: "exec", provider: "execmain", id: "providers/moonshot/apiKey" },
|
||||||
|
models: [{ id: "moonshot-v1-8k", name: "moonshot-v1-8k" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await fs.rm(authStorePath, { force: true });
|
||||||
|
await fs.writeFile(envPath, "", "utf8");
|
||||||
|
|
||||||
|
const report = await runSecretsAudit({ env });
|
||||||
|
expect(report.summary.unresolvedRefCount).toBe(0);
|
||||||
|
|
||||||
|
const callLog = await fs.readFile(execLogPath, "utf8");
|
||||||
|
const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length;
|
||||||
|
expect(callCount).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
|
|||||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||||
import { createSecretsConfigIO } from "./config-io.js";
|
import { createSecretsConfigIO } from "./config-io.js";
|
||||||
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
|
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
|
||||||
import { resolveSecretRefValue, type SecretRefResolveCache } from "./resolve.js";
|
import { secretRefKey } from "./ref-contract.js";
|
||||||
|
import {
|
||||||
|
resolveSecretRefValue,
|
||||||
|
resolveSecretRefValues,
|
||||||
|
type SecretRefResolveCache,
|
||||||
|
} from "./resolve.js";
|
||||||
import { isNonEmptyString, isRecord } from "./shared.js";
|
import { isNonEmptyString, isRecord } from "./shared.js";
|
||||||
|
|
||||||
export type SecretsAuditCode =
|
export type SecretsAuditCode =
|
||||||
@@ -154,16 +159,26 @@ function collectEnvPlaintext(params: { envPath: string; collector: AuditCollecto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJsonObject(filePath: string): Record<string, unknown> | null {
|
function readJsonObject(filePath: string): {
|
||||||
|
value: Record<string, unknown> | null;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return null;
|
return { value: null };
|
||||||
}
|
}
|
||||||
const raw = fs.readFileSync(filePath, "utf8");
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
if (!isRecord(parsed)) {
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return null;
|
if (!isRecord(parsed)) {
|
||||||
|
return { value: null };
|
||||||
|
}
|
||||||
|
return { value: parsed };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectConfigSecrets(params: {
|
function collectConfigSecrets(params: {
|
||||||
@@ -322,7 +337,18 @@ function collectAuthStoreSecrets(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
params.collector.filesScanned.add(params.authStorePath);
|
params.collector.filesScanned.add(params.authStorePath);
|
||||||
const parsed = readJsonObject(params.authStorePath);
|
const parsedResult = readJsonObject(params.authStorePath);
|
||||||
|
if (parsedResult.error) {
|
||||||
|
addFinding(params.collector, {
|
||||||
|
code: "REF_UNRESOLVED",
|
||||||
|
severity: "error",
|
||||||
|
file: params.authStorePath,
|
||||||
|
jsonPath: "<root>",
|
||||||
|
message: `Invalid JSON in auth-profiles store: ${parsedResult.error}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parsedResult.value;
|
||||||
if (!parsed || !isRecord(parsed.profiles)) {
|
if (!parsed || !isRecord(parsed.profiles)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -420,7 +446,18 @@ function collectAuthJsonResidue(params: { stateDir: string; collector: AuditColl
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
params.collector.filesScanned.add(authJsonPath);
|
params.collector.filesScanned.add(authJsonPath);
|
||||||
const parsed = readJsonObject(authJsonPath);
|
const parsedResult = readJsonObject(authJsonPath);
|
||||||
|
if (parsedResult.error) {
|
||||||
|
addFinding(params.collector, {
|
||||||
|
code: "REF_UNRESOLVED",
|
||||||
|
severity: "error",
|
||||||
|
file: authJsonPath,
|
||||||
|
jsonPath: "<root>",
|
||||||
|
message: `Invalid JSON in legacy auth.json: ${parsedResult.error}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parsedResult.value;
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -448,27 +485,99 @@ async function collectUnresolvedRefFindings(params: {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const cache: SecretRefResolveCache = {};
|
const cache: SecretRefResolveCache = {};
|
||||||
|
const refsByProvider = new Map<string, Map<string, SecretRef>>();
|
||||||
for (const assignment of params.collector.refAssignments) {
|
for (const assignment of params.collector.refAssignments) {
|
||||||
|
const providerKey = `${assignment.ref.source}:${assignment.ref.provider}`;
|
||||||
|
let refsForProvider = refsByProvider.get(providerKey);
|
||||||
|
if (!refsForProvider) {
|
||||||
|
refsForProvider = new Map<string, SecretRef>();
|
||||||
|
refsByProvider.set(providerKey, refsForProvider);
|
||||||
|
}
|
||||||
|
refsForProvider.set(secretRefKey(assignment.ref), assignment.ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedByRefKey = new Map<string, unknown>();
|
||||||
|
const errorsByRefKey = new Map<string, unknown>();
|
||||||
|
|
||||||
|
for (const refsForProvider of refsByProvider.values()) {
|
||||||
|
const refs = [...refsForProvider.values()];
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveSecretRefValue(assignment.ref, {
|
const resolved = await resolveSecretRefValues(refs, {
|
||||||
config: params.config,
|
config: params.config,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
cache,
|
cache,
|
||||||
});
|
});
|
||||||
if (assignment.expected === "string") {
|
for (const [key, value] of resolved.entries()) {
|
||||||
if (!isNonEmptyString(resolved)) {
|
resolvedByRefKey.set(key, value);
|
||||||
throw new Error("resolved value is not a non-empty string");
|
|
||||||
}
|
|
||||||
} else if (!(isNonEmptyString(resolved) || isRecord(resolved))) {
|
|
||||||
throw new Error("resolved value is not a string/object");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
continue;
|
||||||
|
} catch {
|
||||||
|
// Fall back to per-ref resolution for provider-specific pinpoint errors.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const key = secretRefKey(ref);
|
||||||
|
try {
|
||||||
|
const resolved = await resolveSecretRefValue(ref, {
|
||||||
|
config: params.config,
|
||||||
|
env: params.env,
|
||||||
|
cache,
|
||||||
|
});
|
||||||
|
resolvedByRefKey.set(key, resolved);
|
||||||
|
} catch (err) {
|
||||||
|
errorsByRefKey.set(key, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignment of params.collector.refAssignments) {
|
||||||
|
const key = secretRefKey(assignment.ref);
|
||||||
|
const resolveErr = errorsByRefKey.get(key);
|
||||||
|
if (resolveErr) {
|
||||||
addFinding(params.collector, {
|
addFinding(params.collector, {
|
||||||
code: "REF_UNRESOLVED",
|
code: "REF_UNRESOLVED",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
file: assignment.file,
|
file: assignment.file,
|
||||||
jsonPath: assignment.path,
|
jsonPath: assignment.path,
|
||||||
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (${String(err)}).`,
|
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (${describeUnknownError(resolveErr)}).`,
|
||||||
|
provider: assignment.provider,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedByRefKey.has(key)) {
|
||||||
|
addFinding(params.collector, {
|
||||||
|
code: "REF_UNRESOLVED",
|
||||||
|
severity: "error",
|
||||||
|
file: assignment.file,
|
||||||
|
jsonPath: assignment.path,
|
||||||
|
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is missing).`,
|
||||||
|
provider: assignment.provider,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolvedByRefKey.get(key);
|
||||||
|
if (assignment.expected === "string") {
|
||||||
|
if (!isNonEmptyString(resolved)) {
|
||||||
|
addFinding(params.collector, {
|
||||||
|
code: "REF_UNRESOLVED",
|
||||||
|
severity: "error",
|
||||||
|
file: assignment.file,
|
||||||
|
jsonPath: assignment.path,
|
||||||
|
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a non-empty string).`,
|
||||||
|
provider: assignment.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(isNonEmptyString(resolved) || isRecord(resolved))) {
|
||||||
|
addFinding(params.collector, {
|
||||||
|
code: "REF_UNRESOLVED",
|
||||||
|
severity: "error",
|
||||||
|
file: assignment.file,
|
||||||
|
jsonPath: assignment.path,
|
||||||
|
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a string/object).`,
|
||||||
provider: assignment.provider,
|
provider: assignment.provider,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -495,6 +604,21 @@ function collectShadowingFindings(collector: AuditCollector): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeUnknownError(err: unknown): string {
|
||||||
|
if (err instanceof Error && err.message.trim().length > 0) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
if (typeof err === "string" && err.trim().length > 0) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(err);
|
||||||
|
return serialized ?? "unknown error";
|
||||||
|
} catch {
|
||||||
|
return "unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport["summary"] {
|
function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport["summary"] {
|
||||||
return {
|
return {
|
||||||
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
|
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isRecord } from "./shared.js";
|
|||||||
type ConfigureCandidate = {
|
type ConfigureCandidate = {
|
||||||
type: "models.providers.apiKey" | "skills.entries.apiKey" | "channels.googlechat.serviceAccount";
|
type: "models.providers.apiKey" | "skills.entries.apiKey" | "channels.googlechat.serviceAccount";
|
||||||
path: string;
|
path: string;
|
||||||
|
pathSegments: string[];
|
||||||
label: string;
|
label: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -134,6 +135,7 @@ function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
|
|||||||
out.push({
|
out.push({
|
||||||
type: "models.providers.apiKey",
|
type: "models.providers.apiKey",
|
||||||
path: `models.providers.${providerId}.apiKey`,
|
path: `models.providers.${providerId}.apiKey`,
|
||||||
|
pathSegments: ["models", "providers", providerId, "apiKey"],
|
||||||
label: `Provider API key: ${providerId}`,
|
label: `Provider API key: ${providerId}`,
|
||||||
providerId,
|
providerId,
|
||||||
});
|
});
|
||||||
@@ -149,6 +151,7 @@ function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
|
|||||||
out.push({
|
out.push({
|
||||||
type: "skills.entries.apiKey",
|
type: "skills.entries.apiKey",
|
||||||
path: `skills.entries.${entryId}.apiKey`,
|
path: `skills.entries.${entryId}.apiKey`,
|
||||||
|
pathSegments: ["skills", "entries", entryId, "apiKey"],
|
||||||
label: `Skill API key: ${entryId}`,
|
label: `Skill API key: ${entryId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,7 @@ function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
|
|||||||
out.push({
|
out.push({
|
||||||
type: "channels.googlechat.serviceAccount",
|
type: "channels.googlechat.serviceAccount",
|
||||||
path: "channels.googlechat.serviceAccount",
|
path: "channels.googlechat.serviceAccount",
|
||||||
|
pathSegments: ["channels", "googlechat", "serviceAccount"],
|
||||||
label: "Google Chat serviceAccount (default)",
|
label: "Google Chat serviceAccount (default)",
|
||||||
});
|
});
|
||||||
const accounts = googlechat.accounts;
|
const accounts = googlechat.accounts;
|
||||||
@@ -170,6 +174,7 @@ function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
|
|||||||
out.push({
|
out.push({
|
||||||
type: "channels.googlechat.serviceAccount",
|
type: "channels.googlechat.serviceAccount",
|
||||||
path: `channels.googlechat.accounts.${accountId}.serviceAccount`,
|
path: `channels.googlechat.accounts.${accountId}.serviceAccount`,
|
||||||
|
pathSegments: ["channels", "googlechat", "accounts", accountId, "serviceAccount"],
|
||||||
label: `Google Chat serviceAccount (${accountId})`,
|
label: `Google Chat serviceAccount (${accountId})`,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
@@ -845,6 +850,7 @@ export async function runSecretsConfigureInteractive(
|
|||||||
targets: [...selectedByPath.values()].map((entry) => ({
|
targets: [...selectedByPath.values()].map((entry) => ({
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
|
pathSegments: [...entry.pathSegments],
|
||||||
ref: entry.ref,
|
ref: entry.ref,
|
||||||
...(entry.providerId ? { providerId: entry.providerId } : {}),
|
...(entry.providerId ? { providerId: entry.providerId } : {}),
|
||||||
...(entry.accountId ? { accountId: entry.accountId } : {}),
|
...(entry.accountId ? { accountId: entry.accountId } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
|
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
|
||||||
|
import { SecretProviderSchema } from "../config/zod-schema.core.js";
|
||||||
|
|
||||||
export type SecretsPlanTargetType =
|
export type SecretsPlanTargetType =
|
||||||
| "models.providers.apiKey"
|
| "models.providers.apiKey"
|
||||||
@@ -12,6 +13,11 @@ export type SecretsPlanTarget = {
|
|||||||
* Example: "models.providers.openai.apiKey"
|
* Example: "models.providers.openai.apiKey"
|
||||||
*/
|
*/
|
||||||
path: string;
|
path: string;
|
||||||
|
/**
|
||||||
|
* Canonical path segments used for safe mutation.
|
||||||
|
* Example: ["models", "providers", "openai", "apiKey"]
|
||||||
|
*/
|
||||||
|
pathSegments?: string[];
|
||||||
ref: SecretRef;
|
ref: SecretRef;
|
||||||
/**
|
/**
|
||||||
* For provider targets, used to scrub auth-profile/static residues.
|
* For provider targets, used to scrub auth-profile/static residues.
|
||||||
@@ -44,70 +50,8 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is string[] {
|
|
||||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig {
|
function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig {
|
||||||
if (!isObjectRecord(value) || typeof value.source !== "string") {
|
return SecretProviderSchema.safeParse(value).success;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.source === "env") {
|
|
||||||
if (value.allowlist !== undefined && !isStringArray(value.allowlist)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.source === "file") {
|
|
||||||
if (typeof value.path !== "string" || value.path.trim().length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.mode !== undefined && value.mode !== "json" && value.mode !== "singleValue") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.source === "exec") {
|
|
||||||
if (typeof value.command !== "string" || value.command.trim().length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.args !== undefined && !isStringArray(value.args)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
value.passEnv !== undefined &&
|
|
||||||
(!Array.isArray(value.passEnv) || !value.passEnv.every((entry) => typeof entry === "string"))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
value.trustedDirs !== undefined &&
|
|
||||||
(!Array.isArray(value.trustedDirs) ||
|
|
||||||
!value.trustedDirs.every((entry) => typeof entry === "string"))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.allowInsecurePath !== undefined && typeof value.allowInsecurePath !== "boolean") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.allowSymlinkCommand !== undefined && typeof value.allowSymlinkCommand !== "boolean") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.env !== undefined) {
|
|
||||||
if (!isObjectRecord(value.env)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!Object.values(value.env).every((entry) => typeof entry === "string")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
||||||
@@ -130,6 +74,12 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
|||||||
candidate.type !== "channels.googlechat.serviceAccount") ||
|
candidate.type !== "channels.googlechat.serviceAccount") ||
|
||||||
typeof candidate.path !== "string" ||
|
typeof candidate.path !== "string" ||
|
||||||
!candidate.path.trim() ||
|
!candidate.path.trim() ||
|
||||||
|
(candidate.pathSegments !== undefined &&
|
||||||
|
(!Array.isArray(candidate.pathSegments) ||
|
||||||
|
candidate.pathSegments.length === 0 ||
|
||||||
|
candidate.pathSegments.some(
|
||||||
|
(segment) => typeof segment !== "string" || segment.trim().length === 0,
|
||||||
|
))) ||
|
||||||
!ref ||
|
!ref ||
|
||||||
typeof ref !== "object" ||
|
typeof ref !== "object" ||
|
||||||
(ref.source !== "env" && ref.source !== "file" && ref.source !== "exec") ||
|
(ref.source !== "env" && ref.source !== "file" && ref.source !== "exec") ||
|
||||||
|
|||||||
Reference in New Issue
Block a user