import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isNonSecretApiKeyMarker, isSecretRefHeaderValueMarker, } from "../agents/model-auth-markers.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveStateDir, type OpenSkynetConfig } from "../config/config.js"; import { coerceSecretRef } from "../config/types.secrets.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js"; import { createSecretsConfigIO } from "./config-io.js"; import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js"; import { listKnownSecretEnvVarNames } from "./provider-env-vars.js"; import { secretRefKey } from "./ref-contract.js"; import { isProviderScopedSecretResolutionError, resolveSecretRefValue, resolveSecretRefValues, type SecretRefResolveCache, } from "./resolve.js"; import { hasConfiguredPlaintextSecretValue, isExpectedResolvedSecretValue, } from "./secret-value.js"; import { isNonEmptyString, isRecord } from "./shared.js"; import { describeUnknownError } from "./shared.js"; import { listAgentModelsJsonPaths, listAuthProfileStorePaths, listLegacyAuthJsonPaths, parseEnvAssignmentValue, readJsonObjectIfExists, } from "./storage-scan.js"; import { discoverConfigSecretTargets } from "./target-registry.js"; export type SecretsAuditCode = | "PLAINTEXT_FOUND" | "REF_UNRESOLVED" | "REF_SHADOWED" | "LEGACY_RESIDUE"; export type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret export type SecretsAuditFinding = { code: SecretsAuditCode; severity: SecretsAuditSeverity; file: string; jsonPath: string; message: string; provider?: string; profileId?: string; }; export type SecretsAuditStatus = "clean" | "findings" | "unresolved"; // pragma: allowlist secret export type SecretsAuditReport = { version: 1; status: SecretsAuditStatus; resolution: { refsChecked: number; skippedExecRefs: number; resolvabilityComplete: boolean; }; 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; authProviderState: Map; filesScanned: Set; }; const REF_RESOLVE_FALLBACK_CONCURRENCY = 8; const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024; const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ "authorization", "proxy-authorization", "x-api-key", "api-key", "apikey", "x-auth-token", "auth-token", "x-access-token", "access-token", "x-secret-key", "secret-key", ]); const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ "api-key", "apikey", "token", "secret", "password", "credential", ]; function isLikelySensitiveModelProviderHeaderName(value: string): boolean { const normalized = value.trim().toLowerCase(); if (!normalized) { return false; } if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) { return true; } return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) => normalized.includes(fragment), ); } 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 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 = parseEnvAssignmentValue(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 collectConfigSecrets(params: { config: OpenSkynetConfig; configPath: string; collector: AuditCollector; }): void { const defaults = params.config.secrets?.defaults; for (const target of discoverConfigSecretTargets(params.config)) { if (!target.entry.includeInAudit) { continue; } const { ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, defaults, }); if (ref) { params.collector.refAssignments.push({ file: params.configPath, path: target.path, ref, expected: target.entry.expectedResolvedValue, provider: target.providerId, }); if (target.entry.trackProviderShadowing && target.providerId) { collectProviderRefPath(params.collector, target.providerId, target.path); } continue; } const hasPlaintext = hasConfiguredPlaintextSecretValue( target.value, target.entry.expectedResolvedValue, ); if ( target.entry.id === "models.providers.*.headers.*" && !isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "") ) { continue; } if (!hasPlaintext) { continue; } addFinding(params.collector, { code: "PLAINTEXT_FOUND", severity: "warn", file: params.configPath, jsonPath: target.path, message: `${target.path} is stored as plaintext.`, provider: target.providerId, }); } } function collectAuthStoreSecrets(params: { authStorePath: string; collector: AuditCollector; defaults?: SecretDefaults; }): void { if (!fs.existsSync(params.authStorePath)) { return; } params.collector.filesScanned.add(params.authStorePath); const parsedResult = readJsonObjectIfExists(params.authStorePath); if (parsedResult.error) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: params.authStorePath, jsonPath: "", message: `Invalid JSON in auth-profiles store: ${parsedResult.error}`, }); return; } const parsed = parsedResult.value; if (!parsed || !isRecord(parsed.profiles)) { return; } for (const entry of iterateAuthProfileCredentials(parsed.profiles)) { if (entry.kind === "api_key" || entry.kind === "token") { const { ref } = resolveSecretInputRef({ value: entry.value, refValue: entry.refValue, defaults: params.defaults, }); if (ref) { params.collector.refAssignments.push({ file: params.authStorePath, path: `profiles.${entry.profileId}.${entry.valueField}`, ref, expected: "string", provider: entry.provider, }); trackAuthProviderState(params.collector, entry.provider, entry.kind); } if (isNonEmptyString(entry.value)) { addFinding(params.collector, { code: "PLAINTEXT_FOUND", severity: "warn", file: params.authStorePath, jsonPath: `profiles.${entry.profileId}.${entry.valueField}`, message: entry.kind === "api_key" ? "Auth profile API key is stored as plaintext." : "Auth profile token is stored as plaintext.", provider: entry.provider, profileId: entry.profileId, }); trackAuthProviderState(params.collector, entry.provider, entry.kind); } continue; } if (entry.hasAccess || entry.hasRefresh) { addFinding(params.collector, { code: "LEGACY_RESIDUE", severity: "info", file: params.authStorePath, jsonPath: `profiles.${entry.profileId}`, message: "OAuth credentials are present (out of scope for static SecretRef migration).", provider: entry.provider, profileId: entry.profileId, }); trackAuthProviderState(params.collector, entry.provider, "oauth"); } } } function collectAuthJsonResidue(params: { stateDir: string; collector: AuditCollector }): void { for (const authJsonPath of listLegacyAuthJsonPaths(params.stateDir)) { params.collector.filesScanned.add(authJsonPath); const parsedResult = readJsonObjectIfExists(authJsonPath); if (parsedResult.error) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: authJsonPath, jsonPath: "", 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, }); } } } } function collectModelsJsonSecrets(params: { modelsJsonPath: string; collector: AuditCollector; }): void { if (!fs.existsSync(params.modelsJsonPath)) { return; } params.collector.filesScanned.add(params.modelsJsonPath); const parsedResult = readJsonObjectIfExists(params.modelsJsonPath, { requireRegularFile: true, maxBytes: MAX_AUDIT_MODELS_JSON_BYTES, }); if (parsedResult.error) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: params.modelsJsonPath, jsonPath: "", message: `Invalid JSON in models.json: ${parsedResult.error}`, }); return; } const parsed = parsedResult.value; if (!parsed || !isRecord(parsed.providers)) { return; } for (const [providerId, providerValue] of Object.entries(parsed.providers)) { if (!isRecord(providerValue)) { continue; } const apiKey = providerValue.apiKey; if (coerceSecretRef(apiKey)) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: params.modelsJsonPath, jsonPath: `providers.${providerId}.apiKey`, message: "models.json contains an unresolved SecretRef object; regenerate models.json.", provider: providerId, }); } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) { addFinding(params.collector, { code: "PLAINTEXT_FOUND", severity: "warn", file: params.modelsJsonPath, jsonPath: `providers.${providerId}.apiKey`, message: "models.json provider apiKey is stored as plaintext.", provider: providerId, }); } const headers = isRecord(providerValue.headers) ? providerValue.headers : undefined; if (!headers) { continue; } for (const [headerKey, headerValue] of Object.entries(headers)) { const headerPath = `providers.${providerId}.headers.${headerKey}`; if (coerceSecretRef(headerValue)) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: params.modelsJsonPath, jsonPath: headerPath, message: "models.json contains an unresolved SecretRef object for provider headers; regenerate models.json.", provider: providerId, }); continue; } if (!isNonEmptyString(headerValue)) { continue; } if (isSecretRefHeaderValueMarker(headerValue)) { continue; } if (!isLikelySensitiveModelProviderHeaderName(headerKey)) { continue; } addFinding(params.collector, { code: "PLAINTEXT_FOUND", severity: "warn", file: params.modelsJsonPath, jsonPath: headerPath, message: "models.json provider header value is stored as plaintext.", provider: providerId, }); } } } async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenSkynetConfig; env: NodeJS.ProcessEnv; allowExec: boolean; }): Promise<{ refsChecked: number; skippedExecRefs: number }> { const cache: SecretRefResolveCache = {}; const refsByProvider = new Map>(); const skippedRefKeys = new Set(); let refsChecked = 0; let skippedExecRefs = 0; 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(); refsByProvider.set(providerKey, refsForProvider); } refsForProvider.set(secretRefKey(assignment.ref), assignment.ref); } const resolvedByRefKey = new Map(); const errorsByRefKey = new Map(); for (const refsForProvider of refsByProvider.values()) { const refs = [...refsForProvider.values()]; const selectedRefs = selectRefsForExecPolicy({ refs, allowExec: params.allowExec, }); if (selectedRefs.skippedExecRefs.length > 0) { skippedExecRefs += selectedRefs.skippedExecRefs.length; for (const ref of selectedRefs.skippedExecRefs) { skippedRefKeys.add(secretRefKey(ref)); const staticError = getSkippedExecRefStaticError({ ref, config: params.config, }); if (staticError) { errorsByRefKey.set(secretRefKey(ref), new Error(staticError)); } } } if (selectedRefs.refsToResolve.length === 0) { continue; } refsChecked += selectedRefs.refsToResolve.length; const provider = refs[0]?.provider; try { const resolved = await resolveSecretRefValues(selectedRefs.refsToResolve, { config: params.config, env: params.env, cache, }); for (const [key, value] of resolved.entries()) { resolvedByRefKey.set(key, value); } continue; } catch (err) { if (provider && isProviderScopedSecretResolutionError(err)) { for (const ref of selectedRefs.refsToResolve) { errorsByRefKey.set(secretRefKey(ref), err); } continue; } // Fall back to per-ref resolution for provider-specific pinpoint errors. } const tasks: Array<() => Promise<{ key: string; resolved: unknown }>> = selectedRefs.refsToResolve.map( (ref) => async (): Promise<{ key: string; resolved: unknown }> => ({ key: secretRefKey(ref), resolved: await resolveSecretRefValue(ref, { config: params.config, env: params.env, cache, }), }), ); const fallback = await runTasksWithConcurrency({ tasks, limit: Math.min(REF_RESOLVE_FALLBACK_CONCURRENCY, selectedRefs.refsToResolve.length), errorMode: "continue", onTaskError: (error, index) => { const ref = selectedRefs.refsToResolve[index]; if (!ref) { return; } errorsByRefKey.set(secretRefKey(ref), error); }, }); for (const result of fallback.results) { if (!result || typeof result.key !== "string") { continue; } resolvedByRefKey.set(result.key, result.resolved); } } for (const assignment of params.collector.refAssignments) { const key = secretRefKey(assignment.ref); if (skippedRefKeys.has(key) && !errorsByRefKey.has(key)) { continue; } 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 (!isExpectedResolvedSecretValue(resolved, assignment.expected)) { addFinding(params.collector, { code: "REF_UNRESOLVED", severity: "error", file: assignment.file, jsonPath: assignment.path, message: assignment.expected === "string" ? `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a non-empty string).` : `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a string/object).`, provider: assignment.provider, }); } } return { refsChecked, skippedExecRefs, }; } 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; allowExec?: boolean; } = {}, ): Promise { const env = params.env ?? process.env; const allowExec = params.allowExec !== false; 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 OpenSkynetConfig); let resolution = { refsChecked: 0, skippedExecRefs: 0, resolvabilityComplete: true, }; if (snapshot.valid) { collectConfigSecrets({ config, configPath, collector, }); for (const authStorePath of listAuthProfileStorePaths(config, stateDir)) { collectAuthStoreSecrets({ authStorePath, collector, defaults, }); } for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir, env)) { collectModelsJsonSecrets({ modelsJsonPath, collector, }); } const unresolvedRefResult = await collectUnresolvedRefFindings({ collector, config, env, allowExec, }); resolution = { refsChecked: unresolvedRefResult.refsChecked, skippedExecRefs: unresolvedRefResult.skippedExecRefs, resolvabilityComplete: unresolvedRefResult.skippedExecRefs === 0, }; collectShadowingFindings(collector); } else { addFinding(collector, { code: "REF_UNRESOLVED", severity: "error", file: configPath, jsonPath: "", 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, resolution, filesScanned: [...collector.filesScanned].toSorted(), summary, findings: collector.findings, }; } 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; }