From 7edb85f98efb73de4e40569978f99ab3b08a8459 Mon Sep 17 00:00:00 2001 From: joel <13933101+lurkshark@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:29:43 -0800 Subject: [PATCH 1/2] CUS-245: Add --duration to p0 ssh and p0 scp p0 ssh and p0 scp now accept an optional --duration option, in the same format as p0 request and p0 kubeconfig ('10 minutes', '2 hours', '5 days', '1 week'). When it is set, the value is passed as --duration in the underlying "request ssh session" call. When it is not set, the request is identical to what the CLI sent before. Users could not ask for a shorter or longer access window when connecting over SSH or SCP, and had to take the default. The CLI does not parse the value; the backend validates it, as it does for the other commands that take a duration. New tests cover both commands with and without the option. The ssh command tests now mock the AWS tooling check so they pass on machines without the AWS CLI installed. --- src/commands/__tests__/scp.test.ts | 148 ++++++++++++++++++ src/commands/__tests__/ssh.test.ts | 32 ++++ src/commands/scp.ts | 6 + .../__tests__/ssh-provision-request.test.ts | 18 +++ src/commands/shared/ssh.ts | 2 + src/commands/ssh.ts | 6 + 6 files changed, 212 insertions(+) create mode 100644 src/commands/__tests__/scp.test.ts diff --git a/src/commands/__tests__/scp.test.ts b/src/commands/__tests__/scp.test.ts new file mode 100644 index 00000000..78d7955f --- /dev/null +++ b/src/commands/__tests__/scp.test.ts @@ -0,0 +1,148 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { TEST_PUBLIC_KEY } from "../../common/__mocks__/keys"; +import { + fetchCommand, + fetchIntegrationConfig, + fetchSshHostKeys, + fetchStreamingStatus, +} from "../../drivers/api"; +import { AwsSshGenerated, AwsSshPermission } from "../../plugins/aws/types"; +import { sshOrScp } from "../../plugins/ssh"; +import { scpCommand } from "../scp"; +import { noop } from "lodash"; +import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; +import yargs from "yargs"; + +vi.mock("../../drivers/api"); +vi.mock("../../drivers/auth"); +vi.mock("../../drivers/stdio", async (importOriginal) => ({ + ...(await importOriginal()), + print1: vi.fn(), + print2: vi.fn(), +})); +vi.mock("../../plugins/ssh"); +vi.mock("../../common/keys"); +// The AWS CLI and Session Manager plugin aren't installed everywhere tests run +vi.mock("../../plugins/aws/ssm/install", () => ({ + ensureSsmInstall: vi.fn().mockResolvedValue(true), +})); + +const mockSshOrScp = sshOrScp as Mock; +const mockFetchCommand = fetchCommand as Mock; +const mockIntegrationConfig = fetchIntegrationConfig as Mock; +const mockFetchStreamingStatus = fetchStreamingStatus as Mock; +const mockFetchSshHostKeys = fetchSshHostKeys as Mock; + +const MOCK_PERMISSION: AwsSshPermission = { + provider: "aws", + publicKey: TEST_PUBLIC_KEY, + region: "region", + alias: "alias", + resource: { + account: "accountId", + accountId: "accountId", + arn: "arn", + idcRegion: "idcRegion", + idcId: "idcId", + name: "name", + userName: "userName", + instanceId: "instanceId", + }, +}; + +const MOCK_GENERATED = { + resource: { + name: "name", + }, + linuxUserName: "linuxUserName", +} as AwsSshGenerated; + +mockIntegrationConfig.mockResolvedValue({ + config: { + "iam-write": { + ["aws:test-account"]: { + state: "installed", + }, + }, + }, +}); + +/** Simulates a streaming response from the API for ephemeral access. */ +const mockStreaming = () => { + mockFetchCommand.mockImplementationOnce(() => ({ + ok: true, + message: "a message", + id: "abcefg", + isPreexisting: false, + isPersistent: false, + request: { status: "NEW" }, + })); + mockFetchStreamingStatus.mockImplementationOnce(async function* () { + yield { + ok: true, + message: "Request approved", + id: "abcefg", + isPreexisting: false, + isPersistent: false, + request: { + status: "DONE", + id: "abcefg", + generated: MOCK_GENERATED, + permission: MOCK_PERMISSION, + }, + }; + }); +}; + +describe("scp", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchCommand.mockReset(); + mockFetchStreamingStatus.mockReset(); + mockFetchSshHostKeys.mockResolvedValue({ hostKeys: [] }); + mockStreaming(); + }); + + it("should forward --duration to p0 request", async () => { + await scpCommand(yargs()) + .fail(noop) + .parse([ + "scp", + "some-instance:/tmp/remote.txt", + "./local.txt", + "--provider", + "aws", + "--duration", + "10 minutes", + ]); + const requestArgs = mockFetchCommand.mock.calls[0]?.[1].arguments; + const durationIndex = requestArgs.indexOf("--duration"); + expect(durationIndex).toBeGreaterThan(-1); + expect(requestArgs[durationIndex + 1]).toBe("10 minutes"); + expect(mockSshOrScp).toHaveBeenCalled(); + }); + + it("should not send --duration without the flag", async () => { + await scpCommand(yargs()) + .fail(noop) + .parse([ + "scp", + "some-instance:/tmp/remote.txt", + "./local.txt", + "--provider", + "aws", + ]); + const requestArgs = mockFetchCommand.mock.calls[0]?.[1].arguments; + expect(requestArgs).toContain("--public-key"); + expect(requestArgs).not.toContain("--duration"); + }); +}); diff --git a/src/commands/__tests__/ssh.test.ts b/src/commands/__tests__/ssh.test.ts index a1999a67..78c9fce9 100644 --- a/src/commands/__tests__/ssh.test.ts +++ b/src/commands/__tests__/ssh.test.ts @@ -33,6 +33,10 @@ vi.mock("../../drivers/stdio", async (importOriginal) => ({ })); vi.mock("../../plugins/ssh"); vi.mock("../../common/keys"); +// The AWS CLI and Session Manager plugin aren't installed everywhere tests run +vi.mock("../../plugins/aws/ssm/install", () => ({ + ensureSsmInstall: vi.fn().mockResolvedValue(true), +})); const mockSshOrScp = sshOrScp as Mock; const mockPrint1 = print1 as Mock; @@ -145,6 +149,34 @@ describe("ssh", () => { await expect(promise).resolves.toBeDefined(); }); + it("should forward --duration to p0 request", async () => { + mockStreaming(isPersistent); + await sshCommand(yargs()) + .fail(noop) + .parse([ + "ssh", + "some-instance", + "--provider", + "aws", + "--duration", + "10 minutes", + ]); + const requestArgs = mockFetchCommand.mock.calls[0]?.[1].arguments; + const durationIndex = requestArgs.indexOf("--duration"); + expect(durationIndex).toBeGreaterThan(-1); + expect(requestArgs[durationIndex + 1]).toBe("10 minutes"); + }); + + it("should not send --duration without the flag", async () => { + mockStreaming(isPersistent); + await sshCommand(yargs()) + .fail(noop) + .parse(["ssh", "some-instance", "--provider", "aws"]); + const requestArgs = mockFetchCommand.mock.calls[0]?.[1].arguments; + expect(requestArgs).toContain("--public-key"); + expect(requestArgs).not.toContain("--duration"); + }); + it("should wait for access grant/provisioning", async () => { mockStreaming(isPersistent, async () => await sleep(200)); const promise = sshCommand(yargs()).fail(noop).parse(`ssh some-instance`); diff --git a/src/commands/scp.ts b/src/commands/scp.ts index 22957643..9b46793b 100644 --- a/src/commands/scp.ts +++ b/src/commands/scp.ts @@ -37,6 +37,12 @@ export const scpCommand = (yargs: yargs.Argv) => describe: "Reason access is needed", type: "string", }) + .option("duration", { + type: "string", + // Copied from the P0 backend + describe: + "Requested duration for access (format like '10 minutes', '2 hours', '5 days', or '1 week')", + }) .option("account", { type: "string", describe: "The account on which the instance is located", diff --git a/src/commands/shared/__tests__/ssh-provision-request.test.ts b/src/commands/shared/__tests__/ssh-provision-request.test.ts index 77e9862e..a189793d 100644 --- a/src/commands/shared/__tests__/ssh-provision-request.test.ts +++ b/src/commands/shared/__tests__/ssh-provision-request.test.ts @@ -103,4 +103,22 @@ describe("provisionRequest", () => { const retryArgs = mockInnerRequest.mock.calls[1]![0].arguments; expect(retryArgs).not.toContain("--sudo"); }); + + it("should forward duration as --duration", async () => { + const args = { ...baseArgs, duration: "10 minutes" }; + + await provisionRequest(mockAuthn, args, "my-instance"); + + const requestArgs = mockInnerRequest.mock.calls[0]![0].arguments; + const durationIndex = requestArgs.indexOf("--duration"); + expect(durationIndex).toBeGreaterThan(-1); + expect(requestArgs[durationIndex + 1]).toBe("10 minutes"); + }); + + it("should not send --duration when duration is not set", async () => { + await provisionRequest(mockAuthn, baseArgs, "my-instance"); + + const requestArgs = mockInnerRequest.mock.calls[0]![0].arguments; + expect(requestArgs).not.toContain("--duration"); + }); }); diff --git a/src/commands/shared/ssh.ts b/src/commands/shared/ssh.ts index 6fe34628..0e715d10 100644 --- a/src/commands/shared/ssh.ts +++ b/src/commands/shared/ssh.ts @@ -39,6 +39,7 @@ import yargs from "yargs"; export type BaseSshCommandArgs = { sudo?: boolean; reason?: string; + duration?: string; parent?: string; provider?: SupportedSshProvider; debug?: boolean; @@ -189,6 +190,7 @@ export const provisionRequest = async ( ? ["--sudo"] : []), ...(args.reason ? ["--reason", args.reason] : []), + ...(args.duration ? ["--duration", args.duration] : []), ...(args.parent ? ["--parent", args.parent] : []), ], wait: true, diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index e2795d83..ac8f0927 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -47,6 +47,12 @@ export const sshCommand = (yargs: yargs.Argv) => describe: "Reason access is needed", type: "string", }) + .option("duration", { + type: "string", + // Copied from the P0 backend + describe: + "Requested duration for access (format like '10 minutes', '2 hours', '5 days', or '1 week')", + }) .option("parent", { type: "string", describe: From 27097ddad6f1d36e772e3ff898d5012c7803e51c Mon Sep 17 00:00:00 2001 From: joel <13933101+lurkshark@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:34:23 -0800 Subject: [PATCH 2/2] Forward an explicitly empty --duration CodeRabbit pointed out that an empty string was dropped by a truthiness check, so the default window was used instead of letting the backend reject the value. Check for undefined instead and add a test. --- .../shared/__tests__/ssh-provision-request.test.ts | 13 +++++++++++++ src/commands/shared/ssh.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands/shared/__tests__/ssh-provision-request.test.ts b/src/commands/shared/__tests__/ssh-provision-request.test.ts index a189793d..39b304b4 100644 --- a/src/commands/shared/__tests__/ssh-provision-request.test.ts +++ b/src/commands/shared/__tests__/ssh-provision-request.test.ts @@ -121,4 +121,17 @@ describe("provisionRequest", () => { const requestArgs = mockInnerRequest.mock.calls[0]![0].arguments; expect(requestArgs).not.toContain("--duration"); }); + + it("should forward an empty duration so the backend can reject it", async () => { + await provisionRequest( + mockAuthn, + { ...baseArgs, duration: "" }, + "my-instance" + ); + + const requestArgs = mockInnerRequest.mock.calls[0]![0].arguments; + const durationIndex = requestArgs.indexOf("--duration"); + expect(durationIndex).toBeGreaterThan(-1); + expect(requestArgs[durationIndex + 1]).toBe(""); + }); }); diff --git a/src/commands/shared/ssh.ts b/src/commands/shared/ssh.ts index 0e715d10..d9bb174e 100644 --- a/src/commands/shared/ssh.ts +++ b/src/commands/shared/ssh.ts @@ -190,7 +190,7 @@ export const provisionRequest = async ( ? ["--sudo"] : []), ...(args.reason ? ["--reason", args.reason] : []), - ...(args.duration ? ["--duration", args.duration] : []), + ...(args.duration !== undefined ? ["--duration", args.duration] : []), ...(args.parent ? ["--parent", args.parent] : []), ], wait: true,