secrets: harden read-only SecretRef command paths and diagnostics (#47794)
* secrets: harden read-only SecretRef resolution for status and audit * CLI: add SecretRef degrade-safe regression coverage * Docs: align SecretRef status and daemon probe semantics * Security audit: close SecretRef review gaps * Security audit: preserve source auth SecretRef configuredness * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
parent
3f12e90f3e
commit
a2cb81199e
@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
||||
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@ -34,13 +34,15 @@ openclaw daemon uninstall
|
||||
|
||||
## Common options
|
||||
|
||||
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json`
|
||||
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
|
||||
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
|
||||
- lifecycle (`uninstall|start|stop|restart`): `--json`
|
||||
|
||||
Notes:
|
||||
|
||||
- `status` resolves configured auth SecretRefs for probe auth when possible.
|
||||
- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
|
||||
- On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
|
||||
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
|
||||
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.
|
||||
|
||||
@ -31,6 +31,7 @@ Notes:
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
|
||||
@ -111,7 +111,8 @@ Options:
|
||||
Notes:
|
||||
|
||||
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
|
||||
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
|
||||
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy.
|
||||
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
|
||||
|
||||
|
||||
@ -783,6 +783,7 @@ Notes:
|
||||
- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting.
|
||||
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra".
|
||||
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
|
||||
- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds).
|
||||
- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
|
||||
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
||||
|
||||
@ -19,6 +19,8 @@ Related:
|
||||
```bash
|
||||
openclaw security audit
|
||||
openclaw security audit --deep
|
||||
openclaw security audit --deep --password <password>
|
||||
openclaw security audit --deep --token <token>
|
||||
openclaw security audit --fix
|
||||
openclaw security audit --json
|
||||
```
|
||||
@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with
|
||||
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
|
||||
For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security).
|
||||
|
||||
SecretRef behavior:
|
||||
|
||||
- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths.
|
||||
- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing).
|
||||
- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings.
|
||||
|
||||
## JSON output
|
||||
|
||||
Use `--json` for CI/policy checks:
|
||||
|
||||
@ -26,3 +26,4 @@ Notes:
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)).
|
||||
- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible.
|
||||
- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`.
|
||||
- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output.
|
||||
|
||||
@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R
|
||||
There are two broad behaviors:
|
||||
|
||||
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable.
|
||||
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
|
||||
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
|
||||
|
||||
Read-only behavior:
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||
type ChannelMessageActionName,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
@ -709,7 +711,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = options?.config ?? loadConfig();
|
||||
const cfg = options?.config
|
||||
? options.config
|
||||
: (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadConfig(),
|
||||
commandName: "tools.message",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "enforce_resolved",
|
||||
})
|
||||
).resolvedConfig;
|
||||
const action = readStringParam(params, "action", {
|
||||
required: true,
|
||||
}) as ChannelMessageActionName;
|
||||
|
||||
@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
async function resolveTalkApiKey(params: {
|
||||
envKey: string;
|
||||
commandName?: string;
|
||||
mode?: "strict" | "summary";
|
||||
mode?: "enforce_resolved" | "read_only_status";
|
||||
}) {
|
||||
return resolveCommandSecretRefsViaGateway({
|
||||
config: makeTalkApiKeySecretRefConfig(params.envKey),
|
||||
@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
expect(result.diagnostics).toEqual(["memory search ref inactive"]);
|
||||
});
|
||||
|
||||
it("degrades unresolved refs in summary mode instead of throwing", async () => {
|
||||
it("degrades unresolved refs in read-only status mode instead of throwing", async () => {
|
||||
const envKey = "TALK_API_KEY_SUMMARY_MISSING";
|
||||
callGateway.mockResolvedValueOnce({
|
||||
assignments: [],
|
||||
@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
const result = await resolveTalkApiKey({
|
||||
envKey,
|
||||
commandName: "status",
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
|
||||
expect(result.hadUnresolvedTargets).toBe(true);
|
||||
@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy summary mode as a read-only alias", async () => {
|
||||
const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING";
|
||||
callGateway.mockResolvedValueOnce({
|
||||
assignments: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
await withEnvValue(envKey, undefined, async () => {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: makeTalkApiKeySecretRefConfig(envKey),
|
||||
commandName: "status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "summary",
|
||||
});
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
|
||||
expect(result.hadUnresolvedTargets).toBe(true);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses targeted local fallback after an incomplete gateway snapshot", async () => {
|
||||
const envKey = "TALK_API_KEY_PARTIAL_GATEWAY";
|
||||
callGateway.mockResolvedValueOnce({
|
||||
@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
const result = await resolveTalkApiKey({
|
||||
envKey,
|
||||
commandName: "status",
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally");
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
} as OpenClawConfig,
|
||||
commandName: "status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("target-only");
|
||||
@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades unresolved refs in operational read-only mode", async () => {
|
||||
it("degrades unresolved refs in read-only operational mode", async () => {
|
||||
const envKey = "TALK_API_KEY_OPERATIONAL_MISSING";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
} as OpenClawConfig,
|
||||
commandName: "channels resolve",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "operational_readonly",
|
||||
mode: "read_only_operational",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
|
||||
|
||||
@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = {
|
||||
hadUnresolvedTargets: boolean;
|
||||
};
|
||||
|
||||
export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret
|
||||
export type CommandSecretResolutionMode =
|
||||
| "enforce_resolved"
|
||||
| "read_only_status"
|
||||
| "read_only_operational";
|
||||
|
||||
type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret
|
||||
|
||||
type CommandSecretResolutionModeInput =
|
||||
| CommandSecretResolutionMode
|
||||
| LegacyCommandSecretResolutionMode;
|
||||
|
||||
export type CommandSecretTargetState =
|
||||
| "resolved_gateway"
|
||||
@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
|
||||
"tools.web.fetch.firecrawl.",
|
||||
] as const;
|
||||
|
||||
function normalizeCommandSecretResolutionMode(
|
||||
mode?: CommandSecretResolutionModeInput,
|
||||
): CommandSecretResolutionMode {
|
||||
if (!mode || mode === "enforce_resolved" || mode === "strict") {
|
||||
return "enforce_resolved";
|
||||
}
|
||||
if (mode === "read_only_status" || mode === "summary") {
|
||||
return "read_only_status";
|
||||
}
|
||||
return "read_only_operational";
|
||||
}
|
||||
|
||||
function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean {
|
||||
return mode === "enforce_resolved";
|
||||
}
|
||||
|
||||
function dedupeDiagnostics(entries: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
if (params.mode === "strict") {
|
||||
if (enforcesResolvedSecrets(params.mode)) {
|
||||
throw error;
|
||||
}
|
||||
localResolutionDiagnostics.push(
|
||||
@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
analyzed,
|
||||
resolvedState: "resolved_local",
|
||||
});
|
||||
if (params.mode !== "strict" && analyzed.unresolved.length > 0) {
|
||||
if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) {
|
||||
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
|
||||
} else if (analyzed.unresolved.length > 0) {
|
||||
throw new Error(
|
||||
@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics(
|
||||
unresolved: UnresolvedCommandSecretAssignment[],
|
||||
mode: CommandSecretResolutionMode,
|
||||
): string[] {
|
||||
if (mode === "strict") {
|
||||
if (enforcesResolvedSecrets(mode)) {
|
||||
return [];
|
||||
}
|
||||
return unresolved.map(
|
||||
@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: {
|
||||
});
|
||||
setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved);
|
||||
} catch (error) {
|
||||
if (params.mode !== "strict") {
|
||||
if (!enforcesResolvedSecrets(params.mode)) {
|
||||
params.localResolutionDiagnostics.push(
|
||||
`${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`,
|
||||
);
|
||||
@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
config: OpenClawConfig;
|
||||
commandName: string;
|
||||
targetIds: Set<string>;
|
||||
mode?: CommandSecretResolutionMode;
|
||||
mode?: CommandSecretResolutionModeInput;
|
||||
}): Promise<ResolveCommandSecretsResult> {
|
||||
const mode = params.mode ?? "strict";
|
||||
const mode = normalizeCommandSecretResolutionMode(params.mode);
|
||||
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
|
||||
config: params.config,
|
||||
targetIds: params.targetIds,
|
||||
@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
(entry) => !recoveredPaths.has(entry.path),
|
||||
);
|
||||
if (stillUnresolved.length > 0) {
|
||||
if (mode === "strict") {
|
||||
if (enforcesResolvedSecrets(mode)) {
|
||||
throw new Error(
|
||||
`${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
|
||||
);
|
||||
@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mode === "strict") {
|
||||
if (enforcesResolvedSecrets(mode)) {
|
||||
throw error;
|
||||
}
|
||||
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAgentRuntimeCommandSecretTargetIds,
|
||||
getMemoryCommandSecretTargetIds,
|
||||
getSecurityAuditCommandSecretTargetIds,
|
||||
} from "./command-secret-targets.js";
|
||||
|
||||
describe("command secret target ids", () => {
|
||||
@ -21,4 +22,13 @@ describe("command secret target ids", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes gateway auth and channel targets for security audit", () => {
|
||||
const ids = getSecurityAuditCommandSecretTargetIds();
|
||||
expect(ids.has("channels.discord.token")).toBe(true);
|
||||
expect(ids.has("gateway.auth.token")).toBe(true);
|
||||
expect(ids.has("gateway.auth.password")).toBe(true);
|
||||
expect(ids.has("gateway.remote.token")).toBe(true);
|
||||
expect(ids.has("gateway.remote.password")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = {
|
||||
"agents.defaults.memorySearch.remote.",
|
||||
"agents.list[].memorySearch.remote.",
|
||||
]),
|
||||
securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]),
|
||||
} as const;
|
||||
|
||||
function toTargetIdSet(values: readonly string[]): Set<string> {
|
||||
@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set<string> {
|
||||
export function getStatusCommandSecretTargetIds(): Set<string> {
|
||||
return toTargetIdSet(COMMAND_SECRET_TARGETS.status);
|
||||
}
|
||||
|
||||
export function getSecurityAuditCommandSecretTargetIds(): Set<string> {
|
||||
return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit);
|
||||
}
|
||||
|
||||
@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import type { GatewayRestartSnapshot } from "./restart-health.js";
|
||||
|
||||
const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const }));
|
||||
const callGatewayStatusProbe = vi.fn<
|
||||
(opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }>
|
||||
>(async (_opts?: unknown) => ({
|
||||
ok: true,
|
||||
url: "ws://127.0.0.1:19001",
|
||||
error: null,
|
||||
}));
|
||||
const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
|
||||
enabled: true,
|
||||
required: true,
|
||||
@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("degrades safely when daemon probe auth SecretRef is unresolved", async () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
tls: { enabled: true },
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const status = await gatherDaemonStatus({
|
||||
rpc: {},
|
||||
probe: true,
|
||||
deep: false,
|
||||
});
|
||||
|
||||
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
}),
|
||||
);
|
||||
expect(status.rpc?.authWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
tls: { enabled: true },
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
callGatewayStatusProbe.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "gateway closed",
|
||||
url: "wss://127.0.0.1:19001",
|
||||
});
|
||||
|
||||
const status = await gatherDaemonStatus({
|
||||
rpc: {},
|
||||
probe: true,
|
||||
deep: false,
|
||||
});
|
||||
|
||||
expect(status.rpc?.ok).toBe(false);
|
||||
expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable");
|
||||
expect(status.rpc?.authWarning).toContain("probing without configured auth credentials");
|
||||
});
|
||||
|
||||
it("keeps remote probe auth strict when remote token is missing", async () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
|
||||
@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
|
||||
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
|
||||
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { trimToUndefined } from "../../gateway/credentials.js";
|
||||
import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js";
|
||||
import { resolveGatewayBindHost } from "../../gateway/net.js";
|
||||
import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js";
|
||||
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||
@ -112,6 +112,7 @@ export type DaemonStatus = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
url?: string;
|
||||
authWarning?: string;
|
||||
};
|
||||
health?: {
|
||||
healthy: boolean;
|
||||
@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseGatewaySecretRefPathFromError(error: unknown): string | null {
|
||||
return isGatewaySecretRefUnavailableError(error) ? error.path : null;
|
||||
}
|
||||
|
||||
async function loadDaemonConfigContext(
|
||||
serviceEnv?: Record<string, string>,
|
||||
): Promise<DaemonConfigContext> {
|
||||
@ -310,8 +315,11 @@ export async function gatherDaemonStatus(
|
||||
const tlsRuntime = shouldUseLocalTlsRuntime
|
||||
? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls)
|
||||
: undefined;
|
||||
const daemonProbeAuth = opts.probe
|
||||
? await resolveGatewayProbeAuthWithSecretInputs({
|
||||
let daemonProbeAuth: { token?: string; password?: string } | undefined;
|
||||
let rpcAuthWarning: string | undefined;
|
||||
if (opts.probe) {
|
||||
try {
|
||||
daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({
|
||||
cfg: daemonCfg,
|
||||
mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local",
|
||||
env: mergedDaemonEnv as NodeJS.ProcessEnv,
|
||||
@ -319,8 +327,16 @@ export async function gatherDaemonStatus(
|
||||
token: opts.rpc.token,
|
||||
password: opts.rpc.password,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
});
|
||||
} catch (error) {
|
||||
const refPath = parseGatewaySecretRefPathFromError(error);
|
||||
if (!refPath) {
|
||||
throw error;
|
||||
}
|
||||
daemonProbeAuth = undefined;
|
||||
rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`;
|
||||
}
|
||||
}
|
||||
|
||||
const rpc = opts.probe
|
||||
? await probeGatewayStatus({
|
||||
@ -336,6 +352,9 @@ export async function gatherDaemonStatus(
|
||||
configPath: daemonConfigSummary.path,
|
||||
})
|
||||
: undefined;
|
||||
if (rpc?.ok) {
|
||||
rpcAuthWarning = undefined;
|
||||
}
|
||||
const health =
|
||||
opts.probe && loaded
|
||||
? await inspectGatewayRestart({
|
||||
@ -369,7 +388,15 @@ export async function gatherDaemonStatus(
|
||||
port: portStatus,
|
||||
...(portCliStatus ? { portCli: portCliStatus } : {}),
|
||||
lastError,
|
||||
...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}),
|
||||
...(rpc
|
||||
? {
|
||||
rpc: {
|
||||
...rpc,
|
||||
url: gateway.probeUrl,
|
||||
...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(health
|
||||
? {
|
||||
health: {
|
||||
|
||||
@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
|
||||
} else {
|
||||
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
|
||||
if (rpc.authWarning) {
|
||||
defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`);
|
||||
}
|
||||
if (rpc.url) {
|
||||
defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
|
||||
}
|
||||
|
||||
245
src/cli/security-cli.test.ts
Normal file
245
src/cli/security-cli.test.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||
|
||||
const loadConfig = vi.fn();
|
||||
const runSecurityAudit = vi.fn();
|
||||
const fixSecurityFootguns = vi.fn();
|
||||
const resolveCommandSecretRefsViaGateway = vi.fn();
|
||||
const getSecurityAuditCommandSecretTargetIds = vi.fn(
|
||||
() => new Set(["gateway.auth.token", "gateway.auth.password"]),
|
||||
);
|
||||
|
||||
const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => loadConfig(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../security/audit.js", () => ({
|
||||
runSecurityAudit: (opts: unknown) => runSecurityAudit(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../security/fix.js", () => ({
|
||||
fixSecurityFootguns: () => fixSecurityFootguns(),
|
||||
}));
|
||||
|
||||
vi.mock("./command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("./command-secret-targets.js", () => ({
|
||||
getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(),
|
||||
}));
|
||||
|
||||
const { registerSecurityCli } = await import("./security-cli.js");
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerSecurityCli(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("security CLI", () => {
|
||||
beforeEach(() => {
|
||||
resetRuntimeCapture();
|
||||
loadConfig.mockReset();
|
||||
runSecurityAudit.mockReset();
|
||||
fixSecurityFootguns.mockReset();
|
||||
resolveCommandSecretRefsViaGateway.mockReset();
|
||||
getSecurityAuditCommandSecretTargetIds.mockClear();
|
||||
fixSecurityFootguns.mockResolvedValue({
|
||||
changes: [],
|
||||
actions: [],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => {
|
||||
const sourceConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig = {
|
||||
...sourceConfig,
|
||||
gateway: {
|
||||
...sourceConfig.gateway,
|
||||
auth: {
|
||||
...sourceConfig.gateway.auth,
|
||||
token: "resolved-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
loadConfig.mockReturnValue(sourceConfig);
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig,
|
||||
diagnostics: [
|
||||
"security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.",
|
||||
],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
runSecurityAudit.mockResolvedValue({
|
||||
ts: 0,
|
||||
summary: { critical: 0, warn: 1, info: 0 },
|
||||
findings: [
|
||||
{
|
||||
checkId: "gateway.probe_failed",
|
||||
severity: "warn",
|
||||
title: "Gateway probe failed (deep)",
|
||||
detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" });
|
||||
|
||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: sourceConfig,
|
||||
commandName: "security audit",
|
||||
mode: "read_only_status",
|
||||
targetIds: expect.any(Set),
|
||||
}),
|
||||
);
|
||||
expect(runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: resolvedConfig,
|
||||
sourceConfig,
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
);
|
||||
const payload = JSON.parse(String(runtimeLogs.at(-1)));
|
||||
expect(payload.secretDiagnostics).toEqual([
|
||||
"security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards --token to deep probe auth without altering command-level resolver mode", async () => {
|
||||
const sourceConfig = { gateway: { mode: "local" } };
|
||||
loadConfig.mockReturnValue(sourceConfig);
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: sourceConfig,
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
runSecurityAudit.mockResolvedValue({
|
||||
ts: 0,
|
||||
summary: { critical: 0, warn: 0, info: 0 },
|
||||
findings: [],
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(
|
||||
["security", "audit", "--deep", "--token", "explicit-token", "--json"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "read_only_status",
|
||||
}),
|
||||
);
|
||||
expect(runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deep: true,
|
||||
deepProbeAuth: { token: "explicit-token" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards --password to deep probe auth without altering command-level resolver mode", async () => {
|
||||
const sourceConfig = { gateway: { mode: "local" } };
|
||||
loadConfig.mockReturnValue(sourceConfig);
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: sourceConfig,
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
runSecurityAudit.mockResolvedValue({
|
||||
ts: 0,
|
||||
summary: { critical: 0, warn: 0, info: 0 },
|
||||
findings: [],
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(
|
||||
["security", "audit", "--deep", "--password", "explicit-password", "--json"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "read_only_status",
|
||||
}),
|
||||
);
|
||||
expect(runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deep: true,
|
||||
deepProbeAuth: { password: "explicit-password" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards both --token and --password to deep probe auth", async () => {
|
||||
const sourceConfig = { gateway: { mode: "local" } };
|
||||
loadConfig.mockReturnValue(sourceConfig);
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: sourceConfig,
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
runSecurityAudit.mockResolvedValue({
|
||||
ts: 0,
|
||||
summary: { critical: 0, warn: 0, info: 0 },
|
||||
findings: [],
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(
|
||||
[
|
||||
"security",
|
||||
"audit",
|
||||
"--deep",
|
||||
"--token",
|
||||
"explicit-token",
|
||||
"--password",
|
||||
"explicit-password",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deep: true,
|
||||
deepProbeAuth: {
|
||||
token: "explicit-token",
|
||||
password: "explicit-password",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
|
||||
import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
|
||||
type SecurityAuditOptions = {
|
||||
json?: boolean;
|
||||
deep?: boolean;
|
||||
fix?: boolean;
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
function formatSummary(summary: { critical: number; warn: number; info: number }): string {
|
||||
@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) {
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw security audit", "Run a local security audit."],
|
||||
["openclaw security audit --deep", "Include best-effort live Gateway probe checks."],
|
||||
["openclaw security audit --deep --token <token>", "Use explicit token for deep probe."],
|
||||
[
|
||||
"openclaw security audit --deep --password <password>",
|
||||
"Use explicit password for deep probe.",
|
||||
],
|
||||
["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."],
|
||||
["openclaw security audit --json", "Output machine-readable JSON."],
|
||||
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`,
|
||||
@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) {
|
||||
.command("audit")
|
||||
.description("Audit config + local state for common security foot-guns")
|
||||
.option("--deep", "Attempt live Gateway probe (best-effort)", false)
|
||||
.option("--token <token>", "Use explicit gateway token for deep probe auth")
|
||||
.option("--password <password>", "Use explicit gateway password for deep probe auth")
|
||||
.option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false)
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(async (opts: SecurityAuditOptions) => {
|
||||
const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const sourceConfig = loadConfig();
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: sourceConfig,
|
||||
commandName: "security audit",
|
||||
targetIds: getSecurityAuditCommandSecretTargetIds(),
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const report = await runSecurityAudit({
|
||||
config: cfg,
|
||||
sourceConfig,
|
||||
deep: Boolean(opts.deep),
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
deepProbeAuth:
|
||||
opts.token?.trim() || opts.password?.trim()
|
||||
? {
|
||||
...(opts.token?.trim() ? { token: opts.token } : {}),
|
||||
...(opts.password?.trim() ? { password: opts.password } : {}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2),
|
||||
JSON.stringify(
|
||||
fixResult
|
||||
? { fix: fixResult, report, secretDiagnostics }
|
||||
: { ...report, secretDiagnostics },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) {
|
||||
lines.push(heading("OpenClaw security audit"));
|
||||
lines.push(muted(`Summary: ${formatSummary(report.summary)}`));
|
||||
lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`));
|
||||
for (const diagnostic of secretDiagnostics) {
|
||||
lines.push(muted(`[secrets] ${diagnostic}`));
|
||||
}
|
||||
|
||||
if (opts.fix) {
|
||||
lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`));
|
||||
|
||||
@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => {
|
||||
expect(result.account).toBe(account);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.degraded).toBe(false);
|
||||
});
|
||||
|
||||
it("uses plugin enable/configure hooks", async () => {
|
||||
@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => {
|
||||
expect(isConfigured).toHaveBeenCalledWith(account, {});
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.degraded).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps strict mode fail-closed when resolveAccount throws", async () => {
|
||||
const plugin = {
|
||||
id: "demo",
|
||||
config: {
|
||||
listAccountIds: () => ["acc-err"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing secret");
|
||||
},
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
|
||||
await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow(
|
||||
/missing secret/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("degrades safely in read_only mode when resolveAccount throws", async () => {
|
||||
const plugin = {
|
||||
id: "demo",
|
||||
config: {
|
||||
listAccountIds: () => ["acc-err"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing secret");
|
||||
},
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
|
||||
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, {
|
||||
mode: "read_only",
|
||||
commandName: "status",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.degraded).toBe(true);
|
||||
expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers inspectAccount in read_only mode", async () => {
|
||||
const inspectAccount = vi.fn(() => ({ configured: true, enabled: true }));
|
||||
const resolveAccount = vi.fn(() => ({ configured: false, enabled: false }));
|
||||
const plugin = {
|
||||
id: "demo",
|
||||
config: {
|
||||
listAccountIds: () => ["acc-1"],
|
||||
inspectAccount,
|
||||
resolveAccount,
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
|
||||
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, {
|
||||
mode: "read_only",
|
||||
});
|
||||
|
||||
expect(inspectAccount).toHaveBeenCalled();
|
||||
expect(resolveAccount).not.toHaveBeenCalled();
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.degraded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
|
||||
export type ChannelDefaultAccountContext = {
|
||||
accountIds: string[];
|
||||
@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = {
|
||||
account: unknown;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
diagnostics: string[];
|
||||
/**
|
||||
* Indicates read-only resolution was used instead of strict full-account resolution.
|
||||
* This is expected for read_only mode and does not necessarily mean an error occurred.
|
||||
*/
|
||||
degraded: boolean;
|
||||
};
|
||||
|
||||
export type ChannelAccountContextMode = "strict" | "read_only";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getBooleanField(value: unknown, key: string): boolean | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof record[key] === "boolean" ? record[key] : undefined;
|
||||
}
|
||||
|
||||
function formatContextDiagnostic(params: {
|
||||
commandName?: string;
|
||||
pluginId: string;
|
||||
accountId: string;
|
||||
message: string;
|
||||
}): string {
|
||||
const prefix = params.commandName ? `${params.commandName}: ` : "";
|
||||
return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`;
|
||||
}
|
||||
|
||||
export async function resolveDefaultChannelAccountContext(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: OpenClawConfig,
|
||||
options?: { mode?: ChannelAccountContextMode; commandName?: string },
|
||||
): Promise<ChannelDefaultAccountContext> {
|
||||
const mode = options?.mode ?? "strict";
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
cfg,
|
||||
accountIds,
|
||||
});
|
||||
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
||||
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: true;
|
||||
return { accountIds, defaultAccountId, account, enabled, configured };
|
||||
if (mode === "strict") {
|
||||
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
||||
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: true;
|
||||
return {
|
||||
accountIds,
|
||||
defaultAccountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
diagnostics: [],
|
||||
degraded: false,
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics: string[] = [];
|
||||
let degraded = false;
|
||||
|
||||
const inspected =
|
||||
plugin.config.inspectAccount?.(cfg, defaultAccountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId: defaultAccountId,
|
||||
});
|
||||
|
||||
let account = inspected;
|
||||
if (!account) {
|
||||
try {
|
||||
account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
||||
} catch (error) {
|
||||
degraded = true;
|
||||
diagnostics.push(
|
||||
formatContextDiagnostic({
|
||||
commandName: options?.commandName,
|
||||
pluginId: plugin.id,
|
||||
accountId: defaultAccountId,
|
||||
message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
accountIds,
|
||||
defaultAccountId,
|
||||
account: {},
|
||||
enabled: false,
|
||||
configured: false,
|
||||
diagnostics,
|
||||
degraded,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
degraded = true;
|
||||
}
|
||||
|
||||
const inspectEnabled = getBooleanField(account, "enabled");
|
||||
let enabled = inspectEnabled ?? true;
|
||||
if (inspectEnabled === undefined && plugin.config.isEnabled) {
|
||||
try {
|
||||
enabled = plugin.config.isEnabled(account, cfg);
|
||||
} catch (error) {
|
||||
degraded = true;
|
||||
enabled = false;
|
||||
diagnostics.push(
|
||||
formatContextDiagnostic({
|
||||
commandName: options?.commandName,
|
||||
pluginId: plugin.id,
|
||||
accountId: defaultAccountId,
|
||||
message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inspectConfigured = getBooleanField(account, "configured");
|
||||
let configured = inspectConfigured ?? true;
|
||||
if (inspectConfigured === undefined && plugin.config.isConfigured) {
|
||||
try {
|
||||
configured = await plugin.config.isConfigured(account, cfg);
|
||||
} catch (error) {
|
||||
degraded = true;
|
||||
configured = false;
|
||||
diagnostics.push(
|
||||
formatContextDiagnostic({
|
||||
commandName: options?.commandName,
|
||||
pluginId: plugin.id,
|
||||
accountId: defaultAccountId,
|
||||
message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountIds,
|
||||
defaultAccountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
diagnostics,
|
||||
degraded,
|
||||
};
|
||||
}
|
||||
|
||||
172
src/commands/channels.status.command-flow.test.ts
Normal file
172
src/commands/channels.status.command-flow.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn();
|
||||
const resolveCommandSecretRefsViaGateway = vi.fn();
|
||||
const requireValidConfigSnapshot = vi.fn();
|
||||
const listChannelPlugins = vi.fn();
|
||||
const withProgress = vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run());
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime),
|
||||
formatChannelAccountLabel: ({
|
||||
channel,
|
||||
accountId,
|
||||
}: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}) => `${channel} ${accountId}`,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => listChannelPlugins(),
|
||||
getChannelPlugin: (channel: string) =>
|
||||
(listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/progress.js", () => ({
|
||||
withProgress: (opts: unknown, run: () => Promise<unknown>) => withProgress(opts, run),
|
||||
}));
|
||||
|
||||
const { channelsStatusCommand } = await import("./channels/status.js");
|
||||
|
||||
function createTokenOnlyPlugin() {
|
||||
return {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
defaultAccountId: () => "default",
|
||||
inspectAccount: (cfg: { secretResolved?: boolean }) =>
|
||||
cfg.secretResolved
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-discord-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
},
|
||||
resolveAccount: (cfg: { secretResolved?: boolean }) =>
|
||||
cfg.secretResolved
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-discord-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
},
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeCapture() {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const runtime = {
|
||||
log: (message: unknown) => logs.push(String(message)),
|
||||
error: (message: unknown) => errors.push(String(message)),
|
||||
exit: (_code?: number) => undefined,
|
||||
};
|
||||
return { runtime, logs, errors };
|
||||
}
|
||||
|
||||
describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
resolveCommandSecretRefsViaGateway.mockReset();
|
||||
requireValidConfigSnapshot.mockReset();
|
||||
listChannelPlugins.mockReset();
|
||||
withProgress.mockClear();
|
||||
listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
|
||||
});
|
||||
|
||||
it("keeps read-only fallback output when SecretRefs are unresolved", async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: { secretResolved: false, channels: {} },
|
||||
diagnostics: [
|
||||
"channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.",
|
||||
],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: true,
|
||||
});
|
||||
const { runtime, logs, errors } = createRuntimeCapture();
|
||||
|
||||
await channelsStatusCommand({ probe: false }, runtime as never);
|
||||
|
||||
expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true);
|
||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commandName: "channels status",
|
||||
mode: "read_only_status",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
logs.some((line) =>
|
||||
line.includes("[secrets] channels status: channels.discord.token is unavailable"),
|
||||
),
|
||||
).toBe(true);
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("configured, secret unavailable in this command path");
|
||||
expect(joined).toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: { secretResolved: true, channels: {} },
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
const { runtime, logs } = createRuntimeCapture();
|
||||
|
||||
await channelsStatusCommand({ probe: false }, runtime as never);
|
||||
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("configured");
|
||||
expect(joined).toContain("token:config");
|
||||
expect(joined).not.toContain("secret unavailable in this command path");
|
||||
expect(joined).not.toContain("token:config (unavailable)");
|
||||
});
|
||||
});
|
||||
@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
|
||||
config: loadedRaw,
|
||||
commandName: "channels resolve",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "operational_readonly",
|
||||
mode: "read_only_operational",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@ -315,7 +315,7 @@ export async function channelsStatusCommand(
|
||||
config: cfg,
|
||||
commandName: "channels status",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
config: cfg,
|
||||
commandName: "doctor --fix",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const inspected = inspectTelegramAccount({ cfg, accountId });
|
||||
|
||||
@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => {
|
||||
expect(message).toContain("direct/DM targets by default");
|
||||
});
|
||||
|
||||
it("degrades safely when channel account resolution fails in read-only security checks", async () => {
|
||||
pluginRegistry.list = [
|
||||
{
|
||||
id: "whatsapp",
|
||||
meta: { label: "WhatsApp" },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing secret");
|
||||
},
|
||||
isEnabled: () => true,
|
||||
isConfigured: () => true,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: () => null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await noteSecurityWarnings({} as OpenClawConfig);
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("[secrets]");
|
||||
expect(message).toContain("failed to resolve account");
|
||||
expect(message).toContain("Run: openclaw security audit --deep");
|
||||
});
|
||||
|
||||
it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
|
||||
@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
||||
if (!plugin.security) {
|
||||
continue;
|
||||
}
|
||||
const { defaultAccountId, account, enabled, configured } =
|
||||
await resolveDefaultChannelAccountContext(plugin, cfg);
|
||||
const { defaultAccountId, account, enabled, configured, diagnostics } =
|
||||
await resolveDefaultChannelAccountContext(plugin, cfg, {
|
||||
mode: "read_only",
|
||||
commandName: "doctor",
|
||||
});
|
||||
for (const diagnostic of diagnostics) {
|
||||
warnings.push(`- [secrets] ${diagnostic}`);
|
||||
}
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -122,4 +122,52 @@ describe("doctor command", () => {
|
||||
"openclaw config set gateway.auth.mode password",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => {
|
||||
mockDoctorConfigSnapshot({
|
||||
config: {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_GATEWAY_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
note.mockClear();
|
||||
try {
|
||||
await doctorCommand(createDoctorRuntime(), {
|
||||
nonInteractive: true,
|
||||
workspaceSuggestions: false,
|
||||
});
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
}
|
||||
}
|
||||
|
||||
const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth");
|
||||
expect(gatewayAuthNote).toBeTruthy();
|
||||
expect(String(gatewayAuthNote?.[0])).toContain(
|
||||
"Gateway token is managed via SecretRef and is currently unavailable.",
|
||||
);
|
||||
expect(String(gatewayAuthNote?.[0])).toContain(
|
||||
"Doctor will not overwrite gateway.auth.token with a plaintext value.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -268,7 +268,7 @@ describe("gateway-status command", () => {
|
||||
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
|
||||
});
|
||||
|
||||
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
|
||||
it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
|
||||
mockLocalTokenEnvRefConfig();
|
||||
@ -276,6 +276,38 @@ describe("gateway-status command", () => {
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||
});
|
||||
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
|
||||
};
|
||||
const unresolvedWarning = parsed.warnings?.find(
|
||||
(warning) =>
|
||||
warning.code === "auth_secretref_unresolved" &&
|
||||
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
|
||||
);
|
||||
expect(unresolvedWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
|
||||
mockLocalTokenEnvRefConfig();
|
||||
probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connection refused",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
});
|
||||
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
|
||||
|
||||
@ -229,7 +229,7 @@ export async function gatewayStatusCommand(
|
||||
});
|
||||
}
|
||||
for (const result of probed) {
|
||||
if (result.authDiagnostics.length === 0) {
|
||||
if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) {
|
||||
continue;
|
||||
}
|
||||
for (const diagnostic of result.authDiagnostics) {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, readBestEffortConfig } from "../config/config.js";
|
||||
@ -161,17 +162,91 @@ const buildSessionSummary = (storePath: string) => {
|
||||
} satisfies HealthSummary["sessions"];
|
||||
};
|
||||
|
||||
const isAccountEnabled = (account: unknown): boolean => {
|
||||
if (!account || typeof account !== "object") {
|
||||
return true;
|
||||
}
|
||||
const enabled = (account as { enabled?: boolean }).enabled;
|
||||
return enabled !== false;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
|
||||
function inspectHealthAccount(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): unknown {
|
||||
return (
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function readBooleanField(value: unknown, key: string): boolean | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof record[key] === "boolean" ? record[key] : undefined;
|
||||
}
|
||||
|
||||
async function resolveHealthAccountContext(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): Promise<{
|
||||
account: unknown;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
diagnostics: string[];
|
||||
}> {
|
||||
const diagnostics: string[] = [];
|
||||
let account: unknown;
|
||||
try {
|
||||
account = params.plugin.config.resolveAccount(params.cfg, params.accountId);
|
||||
} catch (error) {
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
account = inspectHealthAccount(params.plugin, params.cfg, params.accountId);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return {
|
||||
account: {},
|
||||
enabled: false,
|
||||
configured: false,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
const enabledFallback = readBooleanField(account, "enabled") ?? true;
|
||||
let enabled = enabledFallback;
|
||||
if (params.plugin.config.isEnabled) {
|
||||
try {
|
||||
enabled = params.plugin.config.isEnabled(account, params.cfg);
|
||||
} catch (error) {
|
||||
enabled = enabledFallback;
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredFallback = readBooleanField(account, "configured") ?? true;
|
||||
let configured = configuredFallback;
|
||||
if (params.plugin.config.isConfigured) {
|
||||
try {
|
||||
configured = await params.plugin.config.isConfigured(account, params.cfg);
|
||||
} catch (error) {
|
||||
configured = configuredFallback;
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { account, enabled, configured, diagnostics };
|
||||
}
|
||||
|
||||
const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => {
|
||||
const record = asRecord(probe);
|
||||
if (!record) {
|
||||
@ -416,13 +491,14 @@ export async function getHealthSnapshot(params?: {
|
||||
const accountSummaries: Record<string, ChannelAccountHealthSummary> = {};
|
||||
|
||||
for (const accountId of accountIdsToProbe) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
: isAccountEnabled(account);
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: true;
|
||||
const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
if (diagnostics.length > 0) {
|
||||
debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics });
|
||||
}
|
||||
|
||||
let probe: unknown;
|
||||
let lastProbeAt: number | null = null;
|
||||
@ -588,16 +664,20 @@ export async function healthCommand(
|
||||
` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`,
|
||||
);
|
||||
for (const accountId of accountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
const { account, configured, diagnostics } = await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const record = asRecord(account);
|
||||
const tokenSource =
|
||||
record && typeof record.tokenSource === "string" ? record.tokenSource : undefined;
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: true;
|
||||
runtime.log(
|
||||
` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`,
|
||||
);
|
||||
for (const diagnostic of diagnostics) {
|
||||
runtime.log(` ! ${diagnostic}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.log(info("[debug] bindings map"));
|
||||
@ -691,13 +771,31 @@ export async function healthCommand(
|
||||
defaultAccountId,
|
||||
boundAccounts,
|
||||
});
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
plugin.status.logSelfId({
|
||||
account,
|
||||
const accountContext = await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
runtime,
|
||||
includeChannelPrefix: true,
|
||||
accountId,
|
||||
});
|
||||
if (!accountContext.enabled || !accountContext.configured) {
|
||||
continue;
|
||||
}
|
||||
if (accountContext.diagnostics.length > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
plugin.status.logSelfId({
|
||||
account: accountContext.account,
|
||||
cfg,
|
||||
runtime,
|
||||
includeChannelPrefix: true,
|
||||
});
|
||||
} catch (error) {
|
||||
debugHealth("logSelfId.failed", {
|
||||
channel: plugin.id,
|
||||
accountId,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedAgents.length > 0) {
|
||||
|
||||
@ -48,7 +48,7 @@ export async function statusAllCommand(
|
||||
config: loadedRaw,
|
||||
commandName: "status --all",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
|
||||
55
src/commands/status.link-channel.test.ts
Normal file
55
src/commands/status.link-channel.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] }));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => pluginRegistry.list,
|
||||
}));
|
||||
|
||||
import { resolveLinkChannelContext } from "./status.link-channel.js";
|
||||
|
||||
describe("resolveLinkChannelContext", () => {
|
||||
it("returns linked context from read-only inspected account state", async () => {
|
||||
const account = { configured: true, enabled: true };
|
||||
pluginRegistry.list = [
|
||||
{
|
||||
id: "discord",
|
||||
meta: { label: "Discord" },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
inspectAccount: () => account,
|
||||
resolveAccount: () => {
|
||||
throw new Error("should not be called in read-only mode");
|
||||
},
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await resolveLinkChannelContext({} as OpenClawConfig);
|
||||
expect(result?.linked).toBe(true);
|
||||
expect(result?.authAgeMs).toBe(1234);
|
||||
expect(result?.account).toBe(account);
|
||||
});
|
||||
|
||||
it("degrades safely when account resolution throws", async () => {
|
||||
pluginRegistry.list = [
|
||||
{
|
||||
id: "discord",
|
||||
meta: { label: "Discord" },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing secret");
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await resolveLinkChannelContext({} as OpenClawConfig);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -16,7 +16,10 @@ export async function resolveLinkChannelContext(
|
||||
): Promise<LinkChannelContext | null> {
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const { defaultAccountId, account, enabled, configured } =
|
||||
await resolveDefaultChannelAccountContext(plugin, cfg);
|
||||
await resolveDefaultChannelAccountContext(plugin, cfg, {
|
||||
mode: "read_only",
|
||||
commandName: "status",
|
||||
});
|
||||
const snapshot = plugin.config.describeAccount
|
||||
? plugin.config.describeAccount(account, cfg)
|
||||
: ({
|
||||
|
||||
@ -197,7 +197,7 @@ async function scanStatusJsonFast(opts: {
|
||||
config: loadedRaw,
|
||||
commandName: "status --json",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
if (hasPotentialConfiguredChannels(cfg)) {
|
||||
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
|
||||
@ -302,7 +302,7 @@ export async function scanStatus(
|
||||
config: loadedRaw,
|
||||
commandName: "status",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
|
||||
@ -512,6 +512,11 @@ describe("statusCommand", () => {
|
||||
await statusCommand({ json: true }, runtime as never);
|
||||
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
||||
expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull();
|
||||
if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) {
|
||||
expect(
|
||||
payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: {
|
||||
value: params.value,
|
||||
env: params.env,
|
||||
normalize: trimToUndefined,
|
||||
onResolveRefError: (error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
|
||||
cause: error,
|
||||
});
|
||||
onResolveRefError: () => {
|
||||
throw new GatewaySecretRefUnavailableError(params.path);
|
||||
},
|
||||
});
|
||||
if (!value) {
|
||||
|
||||
@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: {
|
||||
cfg: OpenClawConfig;
|
||||
mode: "local" | "remote";
|
||||
env?: NodeJS.ProcessEnv;
|
||||
explicitAuth?: ExplicitGatewayAuth;
|
||||
}): {
|
||||
auth: { token?: string; password?: string };
|
||||
warning?: string;
|
||||
} {
|
||||
const explicitToken = params.explicitAuth?.token?.trim();
|
||||
const explicitPassword = params.explicitAuth?.password?.trim();
|
||||
if (explicitToken || explicitPassword) {
|
||||
return {
|
||||
auth: {
|
||||
...(explicitToken ? { token: explicitToken } : {}),
|
||||
...(explicitPassword ? { password: explicitPassword } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { auth: resolveGatewayProbeAuth(params) };
|
||||
} catch (error) {
|
||||
|
||||
@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
|
||||
@ -164,6 +165,7 @@ export async function collectChannelSecurityFindings(params: {
|
||||
plugin: (typeof params.plugins)[number],
|
||||
accountId: string,
|
||||
) => {
|
||||
const diagnostics: string[] = [];
|
||||
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
||||
const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId);
|
||||
const sourceInspection = sourceInspectedAccount as {
|
||||
@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const resolvedAccount =
|
||||
resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId);
|
||||
let resolvedAccount = resolvedInspectedAccount;
|
||||
if (!resolvedAccount) {
|
||||
try {
|
||||
resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId);
|
||||
} catch (error) {
|
||||
diagnostics.push(
|
||||
`${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!resolvedAccount && sourceInspectedAccount) {
|
||||
resolvedAccount = sourceInspectedAccount;
|
||||
}
|
||||
if (!resolvedAccount) {
|
||||
return {
|
||||
account: {},
|
||||
enabled: false,
|
||||
configured: false,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
const useSourceUnavailableAccount = Boolean(
|
||||
sourceInspectedAccount &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
||||
@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: {
|
||||
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
||||
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
|
||||
const accountRecord = asAccountRecord(account);
|
||||
const enabled =
|
||||
let enabled =
|
||||
typeof selectedInspection?.enabled === "boolean"
|
||||
? selectedInspection.enabled
|
||||
: typeof accountRecord?.enabled === "boolean"
|
||||
? accountRecord.enabled
|
||||
: plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, params.cfg)
|
||||
: true;
|
||||
const configured =
|
||||
: true;
|
||||
if (
|
||||
typeof selectedInspection?.enabled !== "boolean" &&
|
||||
typeof accountRecord?.enabled !== "boolean" &&
|
||||
plugin.config.isEnabled
|
||||
) {
|
||||
try {
|
||||
enabled = plugin.config.isEnabled(account, params.cfg);
|
||||
} catch (error) {
|
||||
enabled = false;
|
||||
diagnostics.push(
|
||||
`${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let configured =
|
||||
typeof selectedInspection?.configured === "boolean"
|
||||
? selectedInspection.configured
|
||||
: typeof accountRecord?.configured === "boolean"
|
||||
? accountRecord.configured
|
||||
: plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, params.cfg)
|
||||
: true;
|
||||
return { account, enabled, configured };
|
||||
: true;
|
||||
if (
|
||||
typeof selectedInspection?.configured !== "boolean" &&
|
||||
typeof accountRecord?.configured !== "boolean" &&
|
||||
plugin.config.isConfigured
|
||||
) {
|
||||
try {
|
||||
configured = await plugin.config.isConfigured(account, params.cfg);
|
||||
} catch (error) {
|
||||
configured = false;
|
||||
diagnostics.push(
|
||||
`${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { account, enabled, configured, diagnostics };
|
||||
};
|
||||
|
||||
const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
|
||||
@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: {
|
||||
plugin.id,
|
||||
accountId,
|
||||
);
|
||||
const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId);
|
||||
const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount(
|
||||
plugin,
|
||||
accountId,
|
||||
);
|
||||
for (const diagnostic of diagnostics) {
|
||||
findings.push({
|
||||
checkId: `channels.${plugin.id}.account.read_only_resolution`,
|
||||
severity: "warn",
|
||||
title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`,
|
||||
detail: diagnostic,
|
||||
remediation:
|
||||
"Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.",
|
||||
});
|
||||
}
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -346,6 +346,43 @@ description: test skill
|
||||
expectNoFinding(res, "gateway.bind_no_auth");
|
||||
});
|
||||
|
||||
it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_GATEWAY_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
secrets: sourceConfig.secrets,
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: resolvedConfig,
|
||||
sourceConfig,
|
||||
env: {},
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expectNoFinding(res, "gateway.bind_no_auth");
|
||||
});
|
||||
|
||||
it("evaluates gateway auth rate-limit warning based on configuration", async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
@ -1805,11 +1842,7 @@ description: test skill
|
||||
it("warns when multiple DM senders share the main session", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: { dmScope: "main" },
|
||||
channels: {
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { enabled: true } },
|
||||
};
|
||||
const plugins: ChannelPlugin[] = [
|
||||
{
|
||||
@ -1984,6 +2017,40 @@ description: test skill
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a read-only resolution warning when channel account resolveAccount throws", async () => {
|
||||
const plugin = stubChannelPlugin({
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing SecretRef");
|
||||
},
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [plugin],
|
||||
});
|
||||
|
||||
const finding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
|
||||
);
|
||||
expect(finding?.severity).toBe("warn");
|
||||
expect(finding?.title).toContain("could not be fully resolved");
|
||||
expect(finding?.detail).toContain("zalouser:default: failed to resolve account");
|
||||
expect(finding?.detail).toContain("missing SecretRef");
|
||||
});
|
||||
|
||||
it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
|
||||
@ -113,6 +113,8 @@ export type SecurityAuditOptions = {
|
||||
configSnapshot?: ConfigFileSnapshot | null;
|
||||
/** Optional cache for code-safety summaries across repeated deep audits. */
|
||||
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
|
||||
/** Optional explicit auth for deep gateway probe. */
|
||||
deepProbeAuth?: { token?: string; password?: string };
|
||||
};
|
||||
|
||||
type AuditExecutionContext = {
|
||||
@ -132,6 +134,7 @@ type AuditExecutionContext = {
|
||||
plugins?: ReturnType<typeof listChannelPlugins>;
|
||||
configSnapshot: ConfigFileSnapshot | null;
|
||||
codeSafetySummaryCache: Map<string, Promise<unknown>>;
|
||||
deepProbeAuth?: { token?: string; password?: string };
|
||||
};
|
||||
|
||||
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
||||
@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: {
|
||||
|
||||
function collectGatewayConfigFindings(
|
||||
cfg: OpenClawConfig,
|
||||
sourceConfig: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
@ -365,18 +369,18 @@ function collectGatewayConfigFindings(
|
||||
hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) ||
|
||||
hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD);
|
||||
const tokenConfiguredFromConfig = hasConfiguredSecretInput(
|
||||
cfg.gateway?.auth?.token,
|
||||
cfg.secrets?.defaults,
|
||||
sourceConfig.gateway?.auth?.token,
|
||||
sourceConfig.secrets?.defaults,
|
||||
);
|
||||
const passwordConfiguredFromConfig = hasConfiguredSecretInput(
|
||||
cfg.gateway?.auth?.password,
|
||||
cfg.secrets?.defaults,
|
||||
sourceConfig.gateway?.auth?.password,
|
||||
sourceConfig.secrets?.defaults,
|
||||
);
|
||||
const remoteTokenConfigured = hasConfiguredSecretInput(
|
||||
cfg.gateway?.remote?.token,
|
||||
cfg.secrets?.defaults,
|
||||
sourceConfig.gateway?.remote?.token,
|
||||
sourceConfig.secrets?.defaults,
|
||||
);
|
||||
const explicitAuthMode = cfg.gateway?.auth?.mode;
|
||||
const explicitAuthMode = sourceConfig.gateway?.auth?.mode;
|
||||
const tokenCanWin =
|
||||
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured;
|
||||
const passwordCanWin =
|
||||
@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
probe: typeof probeGateway;
|
||||
explicitAuth?: { token?: string; password?: string };
|
||||
}): Promise<{
|
||||
deep: SecurityAuditReport["deep"];
|
||||
authWarning?: string;
|
||||
@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: {
|
||||
|
||||
const authResolution =
|
||||
!isRemoteMode || remoteUrlMissing
|
||||
? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" })
|
||||
: resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" });
|
||||
? resolveGatewayProbeAuthSafe({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
mode: "local",
|
||||
explicitAuth: params.explicitAuth,
|
||||
})
|
||||
: resolveGatewayProbeAuthSafe({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
mode: "remote",
|
||||
explicitAuth: params.explicitAuth,
|
||||
});
|
||||
const res = await params
|
||||
.probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs })
|
||||
.catch((err) => ({
|
||||
@ -1144,6 +1159,7 @@ async function createAuditExecutionContext(
|
||||
plugins: opts.plugins,
|
||||
configSnapshot,
|
||||
codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>(),
|
||||
deepProbeAuth: opts.deepProbeAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
|
||||
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
|
||||
|
||||
findings.push(...collectGatewayConfigFindings(cfg, env));
|
||||
findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));
|
||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
@ -1244,6 +1260,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
env,
|
||||
timeoutMs: context.deepTimeoutMs,
|
||||
probe: context.probeGatewayFn ?? probeGateway,
|
||||
explicitAuth: context.deepProbeAuth,
|
||||
})
|
||||
: undefined;
|
||||
const deep = deepProbeResult?.deep;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user