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:
Josh Avant
2026-03-15 21:55:24 -05:00
committed by GitHub
parent 3f12e90f3e
commit a2cb81199e
40 changed files with 1368 additions and 103 deletions

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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}`);
}