mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 23:41:07 +00:00
624 lines
18 KiB
TypeScript
624 lines
18 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
|
|
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
|
import { normalizeProviderId } from "../agents/model-selection.js";
|
|
import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
|
|
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
|
|
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
|
import { createSecretsConfigIO } from "./config-io.js";
|
|
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
|
|
import { resolveSecretRefValue, type SecretRefResolveCache } from "./resolve.js";
|
|
import { isNonEmptyString, isRecord } from "./shared.js";
|
|
|
|
export type SecretsAuditCode =
|
|
| "PLAINTEXT_FOUND"
|
|
| "REF_UNRESOLVED"
|
|
| "REF_SHADOWED"
|
|
| "LEGACY_RESIDUE";
|
|
|
|
export type SecretsAuditSeverity = "info" | "warn" | "error";
|
|
|
|
export type SecretsAuditFinding = {
|
|
code: SecretsAuditCode;
|
|
severity: SecretsAuditSeverity;
|
|
file: string;
|
|
jsonPath: string;
|
|
message: string;
|
|
provider?: string;
|
|
profileId?: string;
|
|
};
|
|
|
|
export type SecretsAuditStatus = "clean" | "findings" | "unresolved";
|
|
|
|
export type SecretsAuditReport = {
|
|
version: 1;
|
|
status: SecretsAuditStatus;
|
|
filesScanned: string[];
|
|
summary: {
|
|
plaintextCount: number;
|
|
unresolvedRefCount: number;
|
|
shadowedRefCount: number;
|
|
legacyResidueCount: number;
|
|
};
|
|
findings: SecretsAuditFinding[];
|
|
};
|
|
|
|
type RefAssignment = {
|
|
file: string;
|
|
path: string;
|
|
ref: SecretRef;
|
|
expected: "string" | "string-or-object";
|
|
provider?: string;
|
|
};
|
|
|
|
type ProviderAuthState = {
|
|
hasUsableStaticOrOAuth: boolean;
|
|
modes: Set<"api_key" | "token" | "oauth">;
|
|
};
|
|
|
|
type SecretDefaults = {
|
|
env?: string;
|
|
file?: string;
|
|
exec?: string;
|
|
};
|
|
|
|
type AuditCollector = {
|
|
findings: SecretsAuditFinding[];
|
|
refAssignments: RefAssignment[];
|
|
configProviderRefPaths: Map<string, string[]>;
|
|
authProviderState: Map<string, ProviderAuthState>;
|
|
filesScanned: Set<string>;
|
|
};
|
|
|
|
function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void {
|
|
collector.findings.push(finding);
|
|
}
|
|
|
|
function collectProviderRefPath(
|
|
collector: AuditCollector,
|
|
providerId: string,
|
|
configPath: string,
|
|
): void {
|
|
const key = normalizeProviderId(providerId);
|
|
const existing = collector.configProviderRefPaths.get(key);
|
|
if (existing) {
|
|
existing.push(configPath);
|
|
return;
|
|
}
|
|
collector.configProviderRefPaths.set(key, [configPath]);
|
|
}
|
|
|
|
function trackAuthProviderState(
|
|
collector: AuditCollector,
|
|
provider: string,
|
|
mode: "api_key" | "token" | "oauth",
|
|
): void {
|
|
const key = normalizeProviderId(provider);
|
|
const existing = collector.authProviderState.get(key);
|
|
if (existing) {
|
|
existing.hasUsableStaticOrOAuth = true;
|
|
existing.modes.add(mode);
|
|
return;
|
|
}
|
|
collector.authProviderState.set(key, {
|
|
hasUsableStaticOrOAuth: true,
|
|
modes: new Set([mode]),
|
|
});
|
|
}
|
|
|
|
function parseDotPath(pathname: string): string[] {
|
|
return pathname.split(".").filter(Boolean);
|
|
}
|
|
|
|
function parseEnvValue(raw: string): string {
|
|
const trimmed = raw.trim();
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function collectEnvPlaintext(params: { envPath: string; collector: AuditCollector }): void {
|
|
if (!fs.existsSync(params.envPath)) {
|
|
return;
|
|
}
|
|
params.collector.filesScanned.add(params.envPath);
|
|
const knownKeys = new Set(listKnownSecretEnvVarNames());
|
|
const raw = fs.readFileSync(params.envPath, "utf8");
|
|
const lines = raw.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const key = match[1] ?? "";
|
|
if (!knownKeys.has(key)) {
|
|
continue;
|
|
}
|
|
const value = parseEnvValue(match[2] ?? "");
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.envPath,
|
|
jsonPath: `$env.${key}`,
|
|
message: `Potential secret found in .env (${key}).`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function readJsonObject(filePath: string): Record<string, unknown> | null {
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!isRecord(parsed)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function collectConfigSecrets(params: {
|
|
config: OpenClawConfig;
|
|
configPath: string;
|
|
collector: AuditCollector;
|
|
}): void {
|
|
const defaults = params.config.secrets?.defaults;
|
|
const providers = params.config.models?.providers as
|
|
| Record<string, { apiKey?: unknown }>
|
|
| undefined;
|
|
if (providers) {
|
|
for (const [providerId, provider] of Object.entries(providers)) {
|
|
const pathLabel = `models.providers.${providerId}.apiKey`;
|
|
const ref = coerceSecretRef(provider.apiKey, defaults);
|
|
if (ref) {
|
|
params.collector.refAssignments.push({
|
|
file: params.configPath,
|
|
path: pathLabel,
|
|
ref,
|
|
expected: "string",
|
|
provider: providerId,
|
|
});
|
|
collectProviderRefPath(params.collector, providerId, pathLabel);
|
|
continue;
|
|
}
|
|
if (isNonEmptyString(provider.apiKey)) {
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.configPath,
|
|
jsonPath: pathLabel,
|
|
message: "Provider apiKey is stored as plaintext.",
|
|
provider: providerId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const entries = params.config.skills?.entries as Record<string, { apiKey?: unknown }> | undefined;
|
|
if (entries) {
|
|
for (const [entryId, entry] of Object.entries(entries)) {
|
|
const pathLabel = `skills.entries.${entryId}.apiKey`;
|
|
const ref = coerceSecretRef(entry.apiKey, defaults);
|
|
if (ref) {
|
|
params.collector.refAssignments.push({
|
|
file: params.configPath,
|
|
path: pathLabel,
|
|
ref,
|
|
expected: "string",
|
|
});
|
|
continue;
|
|
}
|
|
if (isNonEmptyString(entry.apiKey)) {
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.configPath,
|
|
jsonPath: pathLabel,
|
|
message: "Skill apiKey is stored as plaintext.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const googlechat = params.config.channels?.googlechat as
|
|
| {
|
|
serviceAccount?: unknown;
|
|
serviceAccountRef?: unknown;
|
|
accounts?: Record<string, unknown>;
|
|
}
|
|
| undefined;
|
|
if (!googlechat) {
|
|
return;
|
|
}
|
|
|
|
const collectGoogleChatValue = (
|
|
value: unknown,
|
|
refValue: unknown,
|
|
pathLabel: string,
|
|
accountId?: string,
|
|
) => {
|
|
const explicitRef = coerceSecretRef(refValue, defaults);
|
|
const inlineRef = explicitRef ? null : coerceSecretRef(value, defaults);
|
|
const ref = explicitRef ?? inlineRef;
|
|
if (ref) {
|
|
params.collector.refAssignments.push({
|
|
file: params.configPath,
|
|
path: pathLabel,
|
|
ref,
|
|
expected: "string-or-object",
|
|
provider: accountId ? "googlechat" : undefined,
|
|
});
|
|
return;
|
|
}
|
|
if (isNonEmptyString(value) || (isRecord(value) && Object.keys(value).length > 0)) {
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.configPath,
|
|
jsonPath: pathLabel,
|
|
message: "Google Chat serviceAccount is stored as plaintext.",
|
|
});
|
|
}
|
|
};
|
|
|
|
collectGoogleChatValue(
|
|
googlechat.serviceAccount,
|
|
googlechat.serviceAccountRef,
|
|
"channels.googlechat.serviceAccount",
|
|
);
|
|
if (!isRecord(googlechat.accounts)) {
|
|
return;
|
|
}
|
|
for (const [accountId, accountValue] of Object.entries(googlechat.accounts)) {
|
|
if (!isRecord(accountValue)) {
|
|
continue;
|
|
}
|
|
collectGoogleChatValue(
|
|
accountValue.serviceAccount,
|
|
accountValue.serviceAccountRef,
|
|
`channels.googlechat.accounts.${accountId}.serviceAccount`,
|
|
accountId,
|
|
);
|
|
}
|
|
}
|
|
|
|
function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] {
|
|
const paths = new Set<string>();
|
|
paths.add(resolveUserPath(resolveAuthStorePath()));
|
|
|
|
const agentsRoot = path.join(resolveUserPath(stateDir), "agents");
|
|
if (fs.existsSync(agentsRoot)) {
|
|
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json"));
|
|
}
|
|
}
|
|
|
|
for (const agentId of listAgentIds(config)) {
|
|
const agentDir = resolveAgentDir(config, agentId);
|
|
paths.add(resolveUserPath(resolveAuthStorePath(agentDir)));
|
|
}
|
|
|
|
return [...paths];
|
|
}
|
|
|
|
function collectAuthStoreSecrets(params: {
|
|
authStorePath: string;
|
|
collector: AuditCollector;
|
|
defaults?: SecretDefaults;
|
|
}): void {
|
|
if (!fs.existsSync(params.authStorePath)) {
|
|
return;
|
|
}
|
|
params.collector.filesScanned.add(params.authStorePath);
|
|
const parsed = readJsonObject(params.authStorePath);
|
|
if (!parsed || !isRecord(parsed.profiles)) {
|
|
return;
|
|
}
|
|
for (const [profileId, profileValue] of Object.entries(parsed.profiles)) {
|
|
if (!isRecord(profileValue) || !isNonEmptyString(profileValue.provider)) {
|
|
continue;
|
|
}
|
|
const provider = String(profileValue.provider);
|
|
if (profileValue.type === "api_key") {
|
|
const keyRef = coerceSecretRef(profileValue.keyRef, params.defaults);
|
|
const inlineRef = keyRef ? null : coerceSecretRef(profileValue.key, params.defaults);
|
|
const ref = keyRef ?? inlineRef;
|
|
if (ref) {
|
|
params.collector.refAssignments.push({
|
|
file: params.authStorePath,
|
|
path: `profiles.${profileId}.key`,
|
|
ref,
|
|
expected: "string",
|
|
provider,
|
|
});
|
|
trackAuthProviderState(params.collector, provider, "api_key");
|
|
}
|
|
if (isNonEmptyString(profileValue.key)) {
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.authStorePath,
|
|
jsonPath: `profiles.${profileId}.key`,
|
|
message: "Auth profile API key is stored as plaintext.",
|
|
provider,
|
|
profileId,
|
|
});
|
|
trackAuthProviderState(params.collector, provider, "api_key");
|
|
}
|
|
continue;
|
|
}
|
|
if (profileValue.type === "token") {
|
|
const tokenRef = coerceSecretRef(profileValue.tokenRef, params.defaults);
|
|
const inlineRef = tokenRef ? null : coerceSecretRef(profileValue.token, params.defaults);
|
|
const ref = tokenRef ?? inlineRef;
|
|
if (ref) {
|
|
params.collector.refAssignments.push({
|
|
file: params.authStorePath,
|
|
path: `profiles.${profileId}.token`,
|
|
ref,
|
|
expected: "string",
|
|
provider,
|
|
});
|
|
trackAuthProviderState(params.collector, provider, "token");
|
|
}
|
|
if (isNonEmptyString(profileValue.token)) {
|
|
addFinding(params.collector, {
|
|
code: "PLAINTEXT_FOUND",
|
|
severity: "warn",
|
|
file: params.authStorePath,
|
|
jsonPath: `profiles.${profileId}.token`,
|
|
message: "Auth profile token is stored as plaintext.",
|
|
provider,
|
|
profileId,
|
|
});
|
|
trackAuthProviderState(params.collector, provider, "token");
|
|
}
|
|
continue;
|
|
}
|
|
if (profileValue.type === "oauth") {
|
|
const hasAccess = isNonEmptyString(profileValue.access);
|
|
const hasRefresh = isNonEmptyString(profileValue.refresh);
|
|
if (hasAccess || hasRefresh) {
|
|
addFinding(params.collector, {
|
|
code: "LEGACY_RESIDUE",
|
|
severity: "info",
|
|
file: params.authStorePath,
|
|
jsonPath: `profiles.${profileId}`,
|
|
message: "OAuth credentials are present (out of scope for static SecretRef migration).",
|
|
provider,
|
|
profileId,
|
|
});
|
|
trackAuthProviderState(params.collector, provider, "oauth");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectAuthJsonResidue(params: { stateDir: string; collector: AuditCollector }): void {
|
|
const agentsRoot = path.join(resolveUserPath(params.stateDir), "agents");
|
|
if (!fs.existsSync(agentsRoot)) {
|
|
return;
|
|
}
|
|
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const authJsonPath = path.join(agentsRoot, entry.name, "agent", "auth.json");
|
|
if (!fs.existsSync(authJsonPath)) {
|
|
continue;
|
|
}
|
|
params.collector.filesScanned.add(authJsonPath);
|
|
const parsed = readJsonObject(authJsonPath);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
for (const [providerId, value] of Object.entries(parsed)) {
|
|
if (!isRecord(value)) {
|
|
continue;
|
|
}
|
|
if (value.type === "api_key" && isNonEmptyString(value.key)) {
|
|
addFinding(params.collector, {
|
|
code: "LEGACY_RESIDUE",
|
|
severity: "warn",
|
|
file: authJsonPath,
|
|
jsonPath: providerId,
|
|
message: "Legacy auth.json contains static api_key credentials.",
|
|
provider: providerId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function collectUnresolvedRefFindings(params: {
|
|
collector: AuditCollector;
|
|
config: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
}): Promise<void> {
|
|
const cache: SecretRefResolveCache = {};
|
|
for (const assignment of params.collector.refAssignments) {
|
|
try {
|
|
const resolved = await resolveSecretRefValue(assignment.ref, {
|
|
config: params.config,
|
|
env: params.env,
|
|
cache,
|
|
});
|
|
if (assignment.expected === "string") {
|
|
if (!isNonEmptyString(resolved)) {
|
|
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) {
|
|
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} (${String(err)}).`,
|
|
provider: assignment.provider,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectShadowingFindings(collector: AuditCollector): void {
|
|
for (const [provider, paths] of collector.configProviderRefPaths.entries()) {
|
|
const authState = collector.authProviderState.get(provider);
|
|
if (!authState?.hasUsableStaticOrOAuth) {
|
|
continue;
|
|
}
|
|
const modeText = [...authState.modes].join("/");
|
|
for (const configPath of paths) {
|
|
addFinding(collector, {
|
|
code: "REF_SHADOWED",
|
|
severity: "warn",
|
|
file: "openclaw.json",
|
|
jsonPath: configPath,
|
|
message: `Auth profile credentials (${modeText}) take precedence for provider "${provider}", so this config ref may never be used.`,
|
|
provider,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport["summary"] {
|
|
return {
|
|
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
|
|
unresolvedRefCount: findings.filter((entry) => entry.code === "REF_UNRESOLVED").length,
|
|
shadowedRefCount: findings.filter((entry) => entry.code === "REF_SHADOWED").length,
|
|
legacyResidueCount: findings.filter((entry) => entry.code === "LEGACY_RESIDUE").length,
|
|
};
|
|
}
|
|
|
|
export async function runSecretsAudit(
|
|
params: {
|
|
env?: NodeJS.ProcessEnv;
|
|
} = {},
|
|
): Promise<SecretsAuditReport> {
|
|
const env = params.env ?? process.env;
|
|
const previousAuthStoreReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY;
|
|
process.env.OPENCLAW_AUTH_STORE_READONLY = "1";
|
|
try {
|
|
const io = createSecretsConfigIO({ env });
|
|
const snapshot = await io.readConfigFileSnapshot();
|
|
const configPath = resolveUserPath(snapshot.path);
|
|
const defaults = snapshot.valid ? snapshot.config.secrets?.defaults : undefined;
|
|
|
|
const collector: AuditCollector = {
|
|
findings: [],
|
|
refAssignments: [],
|
|
configProviderRefPaths: new Map(),
|
|
authProviderState: new Map(),
|
|
filesScanned: new Set([configPath]),
|
|
};
|
|
|
|
const stateDir = resolveStateDir(env, os.homedir);
|
|
const envPath = path.join(resolveConfigDir(env, os.homedir), ".env");
|
|
const config = snapshot.valid ? snapshot.config : ({} as OpenClawConfig);
|
|
|
|
if (snapshot.valid) {
|
|
collectConfigSecrets({
|
|
config,
|
|
configPath,
|
|
collector,
|
|
});
|
|
for (const authStorePath of collectAuthStorePaths(config, stateDir)) {
|
|
collectAuthStoreSecrets({
|
|
authStorePath,
|
|
collector,
|
|
defaults,
|
|
});
|
|
}
|
|
await collectUnresolvedRefFindings({
|
|
collector,
|
|
config,
|
|
env,
|
|
});
|
|
collectShadowingFindings(collector);
|
|
} else {
|
|
addFinding(collector, {
|
|
code: "REF_UNRESOLVED",
|
|
severity: "error",
|
|
file: configPath,
|
|
jsonPath: "<root>",
|
|
message: "Config is invalid; cannot validate secret references reliably.",
|
|
});
|
|
}
|
|
|
|
collectEnvPlaintext({
|
|
envPath,
|
|
collector,
|
|
});
|
|
collectAuthJsonResidue({
|
|
stateDir,
|
|
collector,
|
|
});
|
|
|
|
const summary = summarizeFindings(collector.findings);
|
|
const status: SecretsAuditStatus =
|
|
summary.unresolvedRefCount > 0
|
|
? "unresolved"
|
|
: collector.findings.length > 0
|
|
? "findings"
|
|
: "clean";
|
|
|
|
return {
|
|
version: 1,
|
|
status,
|
|
filesScanned: [...collector.filesScanned].toSorted(),
|
|
summary,
|
|
findings: collector.findings,
|
|
};
|
|
} finally {
|
|
if (previousAuthStoreReadOnly === undefined) {
|
|
delete process.env.OPENCLAW_AUTH_STORE_READONLY;
|
|
} else {
|
|
process.env.OPENCLAW_AUTH_STORE_READONLY = previousAuthStoreReadOnly;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveSecretsAuditExitCode(report: SecretsAuditReport, check: boolean): number {
|
|
if (report.summary.unresolvedRefCount > 0) {
|
|
return 2;
|
|
}
|
|
if (check && report.findings.length > 0) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function applySecretsPlanTarget(
|
|
config: OpenClawConfig,
|
|
pathLabel: string,
|
|
value: unknown,
|
|
): void {
|
|
const segments = parseDotPath(pathLabel);
|
|
if (segments.length === 0) {
|
|
throw new Error("Invalid target path.");
|
|
}
|
|
let cursor: Record<string, unknown> = config as unknown as Record<string, unknown>;
|
|
for (const segment of segments.slice(0, -1)) {
|
|
const existing = cursor[segment];
|
|
if (!isRecord(existing)) {
|
|
cursor[segment] = {};
|
|
}
|
|
cursor = cursor[segment] as Record<string, unknown>;
|
|
}
|
|
cursor[segments[segments.length - 1]] = value;
|
|
}
|