mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +00:00
756 lines
22 KiB
TypeScript
756 lines
22 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 { secretRefKey } from "./ref-contract.js";
|
|
import {
|
|
resolveSecretRefValue,
|
|
resolveSecretRefValues,
|
|
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): {
|
|
value: Record<string, unknown> | null;
|
|
error?: string;
|
|
} {
|
|
if (!fs.existsSync(filePath)) {
|
|
return { value: null };
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!isRecord(parsed)) {
|
|
return { value: null };
|
|
}
|
|
return { value: parsed };
|
|
} catch (err) {
|
|
return {
|
|
value: null,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
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>();
|
|
// Scope default auth store discovery to the provided stateDir instead of
|
|
// ambient process env, so audits do not include unrelated host-global stores.
|
|
paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"));
|
|
|
|
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)) {
|
|
if (agentId === "main") {
|
|
paths.add(
|
|
path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"),
|
|
);
|
|
continue;
|
|
}
|
|
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 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)) {
|
|
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 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) {
|
|
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 = {};
|
|
const refsByProvider = new Map<string, Map<string, SecretRef>>();
|
|
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 {
|
|
const resolved = await resolveSecretRefValues(refs, {
|
|
config: params.config,
|
|
env: params.env,
|
|
cache,
|
|
});
|
|
for (const [key, value] of resolved.entries()) {
|
|
resolvedByRefKey.set(key, value);
|
|
}
|
|
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, {
|
|
code: "REF_UNRESOLVED",
|
|
severity: "error",
|
|
file: assignment.file,
|
|
jsonPath: assignment.path,
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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"] {
|
|
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;
|
|
}
|