Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add support for Azure SSH using Azure Bastion #119

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commands/shared/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createKeyPair } from "../../common/keys";
import { doc } from "../../drivers/firestore";
import { print2 } from "../../drivers/stdio";
import { awsSshProvider } from "../../plugins/aws/ssh";
import { azureSshProvider } from "../../plugins/azure/ssh";
import { gcpSshProvider } from "../../plugins/google/ssh";
import { SshConfig } from "../../plugins/ssh/types";
import { Authn } from "../../types/identity";
Expand Down Expand Up @@ -59,6 +60,7 @@ export const SSH_PROVIDERS: Record<
> = {
aws: awsSshProvider,
gcloud: gcpSshProvider,
azure: azureSshProvider,
};

const validateSshInstall = async (
Expand Down
5 changes: 3 additions & 2 deletions src/commands/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ You should have received a copy of the GNU General Public License along with @p0
import { authenticate } from "../drivers/auth";
import { guard } from "../drivers/firestore";
import { sshOrScp } from "../plugins/ssh";
import { SshCommandArgs, prepareRequest } from "./shared/ssh";
import { SupportedSshProviders } from "../types/ssh";
import { SSH_PROVIDERS, SshCommandArgs, prepareRequest } from "./shared/ssh";
import yargs from "yargs";

export const sshCommand = (yargs: yargs.Argv) =>
Expand Down Expand Up @@ -50,7 +51,7 @@ export const sshCommand = (yargs: yargs.Argv) =>
.option("provider", {
type: "string",
describe: "The cloud provider where the instance is hosted",
choices: ["aws", "gcloud"],
choices: SupportedSshProviders,
})
.option("debug", {
type: "boolean",
Expand Down
73 changes: 73 additions & 0 deletions src/plugins/azure/ssh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** 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 <https://www.gnu.org/licenses/>.
**/
import { SshProvider } from "../../types/ssh";
import {
AzureSshLocalData,
AzureSshPermissionSpec,
AzureSshRequest,
} from "./types";
import { hierarchyInfo } from "./util";

/** Maximum number of attempts to start an SSH session
*
* The length of each attempt varies based on the type of error from a few seconds to < 1s
*/
const MAX_SSH_RETRIES = 120;

export const azureSshProvider: SshProvider<
AzureSshPermissionSpec,
AzureSshLocalData,
AzureSshRequest
> = {
requestToSsh: (request) => {
return {
// TODO: Azure doesn't use linuxUserName, derived from the user's identity
// `miguel.campos@p0.dev@azure-node-1:~$`
linuxUserName: "",
id: request.permission.spec.instanceId,
bastionHostId: request.permission.spec.bastionHostId,
type: "azure",
};
},
toCliRequest: async (request, _options) => ({
...request,
cliLocalData: undefined,
}),
ensureInstall: async () => {
// TODO: Support Azure Login
},
cloudProviderLogin: async () => undefined, // TODO: Support Azure Login
reproCommands: () => undefined, // TODO: Support Azure Login
proxyCommand: ({ bastionHostId, id }) => {
// az network bastion ssh --name bastions-1 --resource-group bastions-group --target-resource-id /subscriptions/ad1e5b28-ccb7-4bfd-9955-ec0e16b8ae66/resourceGroups/VM-GROUP/providers/Microsoft.Compute/virtualMachines/azure-node-1 --auth-type AAD
const bastion = hierarchyInfo(bastionHostId);
if (!bastion || !bastion.resourceId || !bastion.resourceGroupId) {
throw new Error("Selected bastion is invalid");
}

return [
"network",
"bastion",
"ssh",
"--name",
bastion.resourceId,
"--resource-group",
bastion.resourceGroupId,
"--target-resource-id",
id,
"--auth-type",
"AAD",
];
},
preTestAccessPropagationArgs: () => undefined, // TODO: Determine if Azure requires any special arguments
maxRetries: MAX_SSH_RETRIES,
friendlyName: "Azure",
};
65 changes: 65 additions & 0 deletions src/plugins/azure/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { PermissionSpec } from "../../types/request";
import { CliPermissionSpec } from "../../types/ssh";
import { CommonSshPermissionSpec } from "../ssh/types";

/** 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 <https://www.gnu.org/licenses/>.
**/
export type AzureSshPermission = {
spec: CommonSshPermissionSpec & {
type: "azure";
subscriptionId: string;
subscriptionName: string;
resourceGroupId: string;
bastionHostId: string;
instanceId: string;
accessRoleId: string;
publicKey: string;
name: string;
region: string;
networkInterfaceIds: string[];
tags: Record<string, string>;
sudo?: boolean;
};
type: "session";
};

export type AzureSshGenerated = {
bastionHostRoleAssignmentId: string;
networkCardRoleAssignmentId: string;
virtualMachineRoleAssignmentId: string;
};

export type AzureSshLocalData = undefined;

export type AzureSshRequest = {
linuxUserName: string;
bastionHostId: string;
id: string;
type: "azure";
};

export type AzureSshPermissionSpec = PermissionSpec<
"ssh",
AzureSshPermission,
AzureSshGenerated
>;
export type AzureSsh = CliPermissionSpec<
AzureSshPermissionSpec,
AzureSshLocalData
>;

export type ScopeInfo = {
resourceId: string;
providerType: string;
resourceType: string;
resourceGroupId: string;
subscriptionId: string;
};
33 changes: 33 additions & 0 deletions src/plugins/azure/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ScopeInfo } from "./types";

/** 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 <https://www.gnu.org/licenses/>.
**/
export const hierarchyInfo = (scope: string): Partial<ScopeInfo> => {
const [
_empty,
_subscriptions,
subscriptionId,
_resourceGroups,
resourceGroupId,
_providers,
providerType,
resourceType,
resourceId,
] = scope.split("/");

return {
subscriptionId,
resourceGroupId,
providerType,
resourceType,
resourceId,
};
};
45 changes: 38 additions & 7 deletions src/plugins/ssh/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const DESTINATION_READ_ERROR =
const GOOGLE_LOGIN_MESSAGE =
/You do not currently have an active account selected/;
const SUDO_MESSAGE = /Sorry, user .+ may not run sudo on .+/; // The output of `sudo -v` when the user is not allowed to run sudo
const UNAUTHORIZED_SCOPE_ACCESS_MESSAGE =
/The client '([^']+)' with object id '([^']+)' does not have authorization to perform action '([^']+)' over scope '([^']+)' or the scope is invalid/;
const AZURE_LOGIN_MESSAGE = /Please run 'az login' to setup account/;

/** Maximum amount of time after SSH subprocess starts to check for {@link UNPROVISIONED_ACCESS_MESSAGES}
* in the process's stderr
Expand All @@ -55,6 +58,7 @@ const DEFAULT_VALIDATION_WINDOW_MS = 5e3;
const RETRY_DELAY_MS = 5000;

/**

* AWS
* There are 2 cases of unprovisioned access in AWS
* 1. SSM:StartSession action is missing either on the SSM document (AWS-StartSSHSession) or the EC2 instance
Expand All @@ -81,6 +85,11 @@ const RETRY_DELAY_MS = 5000;
* 5a: results in UNAUTHORIZED_INSTANCES_GET_MESSAGE
* 6: results in SUDO_MESSAGE
* 7: results in DESTINATION_READ_ERROR and also CONNECTION_CLOSED_MESSAGE
*
* Azure
* There is 1 message that is sent for unprovisioned access in Azure
* 1. The user does not have the authorization to perform an action results in UNAUTHORIZED_SCOPE_ACCESS_MESSAGE
* 2. The user is not logged in to Azure CLI results in AZURE_LOGIN_MESSAGE
*/
const UNPROVISIONED_ACCESS_MESSAGES = [
{ pattern: UNAUTHORIZED_START_SESSION_MESSAGE },
Expand All @@ -90,6 +99,7 @@ const UNPROVISIONED_ACCESS_MESSAGES = [
{ pattern: UNAUTHORIZED_TUNNEL_USER_MESSAGE },
{ pattern: UNAUTHORIZED_INSTANCES_GET_MESSAGE, validationWindowMs: 30e3 },
{ pattern: DESTINATION_READ_ERROR },
{ pattern: UNAUTHORIZED_SCOPE_ACCESS_MESSAGE, validationWindowMs: 10e3 },
];

/** Checks if access has propagated through AWS to the SSM agent
Expand All @@ -114,11 +124,11 @@ const accessPropagationGuard = (
) => {
let isEphemeralAccessDeniedException = false;
let isGoogleLoginException = false;
let isAzureLoginException = false;
const beforeStart = Date.now();

child.stderr.on("data", (chunk) => {
const chunkString: string = chunk.toString("utf-8");

if (debug) print2(chunkString);

const match = UNPROVISIONED_ACCESS_MESSAGES.find((message) =>
Expand All @@ -138,11 +148,20 @@ const accessPropagationGuard = (
if (isGoogleLoginException) {
isEphemeralAccessDeniedException = false; // always overwrite to false so we don't retry the access
}

const azureLoginMatch = chunkString.match(AZURE_LOGIN_MESSAGE);
console;
isAzureLoginException = isAzureLoginException || !!azureLoginMatch;
if (isAzureLoginException) {
isEphemeralAccessDeniedException = false; // always overwrite to false so we don't retry the access
}
});

return {
isAccessPropagated: () => !isEphemeralAccessDeniedException,
isGoogleLoginException: () => isGoogleLoginException,
// TODO: check for azure login before starting the ssh session
isAzureLoginException: () => isAzureLoginException,
};
};

Expand Down Expand Up @@ -199,12 +218,20 @@ async function spawnSshNode(
options.credential,
options.command,
options.args,
options.stdio
["inherit", "inherit", "pipe"]
);

// TODO ENG-2284 support login with Google Cloud: currently return a boolean to indicate if the exception was a Google login error.
const { isAccessPropagated, isGoogleLoginException } =
accessPropagationGuard(child, options.debug);
// TODO @ENG-2284 support login with Google Cloud: currently return a boolean to indicate if the exception was a Google login error.
// TODO: Support login with Azure.
const {
isAccessPropagated,
isGoogleLoginException,
isAzureLoginException,
} = accessPropagationGuard(child, options.debug);

child.on("error", (err) => {
reject(err);
});

const exitListener = child.on("exit", (code) => {
exitListener.unref();
Expand Down Expand Up @@ -232,6 +259,9 @@ async function spawnSshNode(
} else if (isGoogleLoginException()) {
reject(`Please login to Google Cloud CLI with 'gcloud auth login'`);
return;
} else if (isAzureLoginException()) {
reject(`Please login to Azure CLI with 'az login'`);
return;
}

options.abortController?.abort(code);
Expand Down Expand Up @@ -410,6 +440,7 @@ export const sshOrScp = async (args: {
const credential: AwsCredentials | undefined =
await sshProvider.cloudProviderLogin(authn, request);

// TODO: azure doesn't need the ssh wrapper.
const proxyCommand = sshProvider.proxyCommand(request);

const { command, args: commandArgs } = createCommand(
Expand Down Expand Up @@ -445,8 +476,8 @@ export const sshOrScp = async (args: {
return spawnSshNode({
credential,
abortController: new AbortController(),
command,
args: commandArgs,
command: "az",
args: proxyCommand,
stdio: ["inherit", "inherit", "pipe"],
debug: cmdArgs.debug,
provider: request.type,
Expand Down
16 changes: 12 additions & 4 deletions src/types/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
AwsSshPermissionSpec,
AwsSshRequest,
} from "../plugins/aws/types";
import {
AzureSsh,
AzureSshPermissionSpec,
AzureSshRequest,
} from "../plugins/azure/types";
import {
GcpSsh,
GcpSshPermissionSpec,
Expand All @@ -22,8 +27,11 @@ import {
import { Authn } from "./identity";
import { Request } from "./request";

export type CliSshRequest = AwsSsh | GcpSsh;
export type PluginSshRequest = AwsSshPermissionSpec | GcpSshPermissionSpec;
export type CliSshRequest = AwsSsh | AzureSsh | GcpSsh;
export type PluginSshRequest =
| AwsSshPermissionSpec
| AzureSshPermissionSpec
| GcpSshPermissionSpec;

export type CliPermissionSpec<
P extends PluginSshRequest,
Expand All @@ -33,7 +41,7 @@ export type CliPermissionSpec<
};

// The prefix of installed SSH accounts in P0 is the provider name
export const SupportedSshProviders = ["aws", "gcloud"] as const;
export const SupportedSshProviders = ["aws", "gcloud", "azure"] as const;
export type SupportedSshProvider = (typeof SupportedSshProviders)[number];

export type SshProvider<
Expand Down Expand Up @@ -70,4 +78,4 @@ export type SshProvider<
friendlyName: string;
};

export type SshRequest = AwsSshRequest | GcpSshRequest;
export type SshRequest = AwsSshRequest | AzureSshRequest | GcpSshRequest;
Binary file added test.txt
Binary file not shown.
Loading