Skip to content

Commit ca06f43

Browse files
Refactor PS login to use a static param()-bound script (#607)
* Refactor PS login to use a static param()-bound script * Update readme * Add injection-safety test for federated token argv handling --------- Co-authored-by: Maddison Das <272712104+MaddyMicrosoft@users.noreply.github.com>
1 parent 905554e commit ca06f43

8 files changed

Lines changed: 287 additions & 265 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ Azure Login Action supports different ways of authentication with Azure.
5757
> [!WARNING]
5858
> Avoid using managed identity login on self-hosted runners in public repositories. Managed identities enable secure authentication with Azure resources and obtain Microsoft Entra ID tokens without the need for explicit credential management. Any user can open pull requests against your repository and access your self-hosted runners without credentials. See more details in [self-hosted runner security](https://docs.github.com/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security).
5959
60+
** **
61+
62+
> [!WARNING]
63+
> Only pass values from `${{ secrets.* }}` into `client-id`, `tenant-id`, `subscription-id`, and `creds`. Do not pipe values from `${{ github.event.* }}` (pull request titles, issue comments, `workflow_dispatch` inputs, branch names, etc.) into these inputs. Untrusted values in these fields can allow attackers to influence the Azure identity the action logs in as.
64+
65+
** **
66+
67+
> [!WARNING]
68+
> Only set `enable-AzPSSession: true` if your workflow runs Azure PowerShell (`Az.*`) cmdlets. If your workflow only uses the Azure CLI (`az ...`), leave `enable-AzPSSession` unset (the default is `false`). Enabling it launches an additional PowerShell login step that is unnecessary for CLI-only workflows.
69+
6070
## Supported Versions
6171

6272
Azure Login follows a major-version support model.

‎__tests__/PowerShell/AzPSScriptBuilder.test.ts‎

Lines changed: 132 additions & 174 deletions
Large diffs are not rendered by default.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "Login Azure wraps the az login, allowing for Azure actions to log into Azure",
44
"main": "lib/main/index.js",
55
"scripts": {
6-
"build:main": "ncc build src/main.ts -o lib/main",
6+
"build:main": "ncc build src/main.ts -o lib/main && node scripts/copy-ps-assets.js",
77
"build:cleanup": "ncc build src/cleanup.ts -o lib/cleanup",
88
"build": "npm run build:main && npm run build:cleanup",
99
"test": "jest"

‎scripts/copy-ps-assets.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copies the static PowerShell login script into the compiled action bundle.
2+
// Run as part of `npm run build:main` so that lib/main/index.js can locate
3+
// AzPSLogin.ps1 via `path.join(__dirname, 'AzPSLogin.ps1')` at runtime.
4+
5+
const fs = require('fs');
6+
const path = require('path');
7+
8+
const src = path.join(__dirname, '..', 'src', 'PowerShell', 'AzPSLogin.ps1');
9+
const dest = path.join(__dirname, '..', 'lib', 'main', 'AzPSLogin.ps1');
10+
11+
fs.mkdirSync(path.dirname(dest), { recursive: true });
12+
fs.copyFileSync(src, dest);
13+
console.log(`Copied ${path.relative(process.cwd(), src)} -> ${path.relative(process.cwd(), dest)}`);

‎src/PowerShell/AzPSLogin.ps1‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
[CmdletBinding()]
2+
param(
3+
[Parameter(Mandatory)]
4+
[ValidateSet('azurecloud', 'azurechinacloud', 'azureusgovernment', 'azuregermancloud', 'azurestack')]
5+
[string]$Environment,
6+
7+
[Parameter(Mandatory)]
8+
[ValidateSet('SERVICE_PRINCIPAL', 'IDENTITY')]
9+
[string]$AuthType,
10+
11+
[string]$Tenant,
12+
13+
[string]$Subscription,
14+
15+
[string]$ApplicationId,
16+
17+
[string]$ArmEndpoint
18+
)
19+
20+
$ErrorActionPreference = 'Stop'
21+
$WarningPreference = 'SilentlyContinue'
22+
23+
try {
24+
if ($Environment -eq 'azurestack') {
25+
if ([string]::IsNullOrEmpty($ArmEndpoint)) {
26+
throw "ArmEndpoint is required when Environment is 'azurestack'."
27+
}
28+
Add-AzEnvironment -Name $Environment -ARMEndpoint $ArmEndpoint | Out-Null
29+
}
30+
31+
$connectArgs = @{
32+
Environment = $Environment
33+
InformationAction = 'Ignore'
34+
}
35+
if ($Tenant) { $connectArgs['Tenant'] = $Tenant }
36+
if ($Subscription) { $connectArgs['Subscription'] = $Subscription }
37+
38+
if ($AuthType -eq 'SERVICE_PRINCIPAL') {
39+
$connectArgs['ServicePrincipal'] = $true
40+
41+
if ($env:AZURE_LOGIN_ACTION__SP_SECRET) {
42+
$secure = ConvertTo-SecureString $env:AZURE_LOGIN_ACTION__SP_SECRET -AsPlainText -Force
43+
$connectArgs['Credential'] = New-Object System.Management.Automation.PSCredential($ApplicationId, $secure)
44+
Remove-Item Env:AZURE_LOGIN_ACTION__SP_SECRET -ErrorAction SilentlyContinue
45+
}
46+
elseif ($env:AZURE_LOGIN_ACTION__FEDERATED_TOKEN) {
47+
$connectArgs['ApplicationId'] = $ApplicationId
48+
$connectArgs['FederatedToken'] = $env:AZURE_LOGIN_ACTION__FEDERATED_TOKEN
49+
Remove-Item Env:AZURE_LOGIN_ACTION__FEDERATED_TOKEN -ErrorAction SilentlyContinue
50+
}
51+
else {
52+
throw "SERVICE_PRINCIPAL auth requires either AZURE_LOGIN_ACTION__SP_SECRET or AZURE_LOGIN_ACTION__FEDERATED_TOKEN in the environment."
53+
}
54+
}
55+
else {
56+
$connectArgs['Identity'] = $true
57+
if ($ApplicationId) {
58+
$connectArgs['AccountId'] = $ApplicationId
59+
}
60+
}
61+
62+
Connect-AzAccount @connectArgs | Out-Null
63+
64+
$output = @{ Success = $true; Result = '' }
65+
}
66+
catch {
67+
$output = @{ Success = $false; Error = $_.Exception.Message }
68+
}
69+
finally {
70+
Remove-Item Env:AZURE_LOGIN_ACTION__SP_SECRET -ErrorAction SilentlyContinue
71+
Remove-Item Env:AZURE_LOGIN_ACTION__FEDERATED_TOKEN -ErrorAction SilentlyContinue
72+
}
73+
74+
ConvertTo-Json $output

‎src/PowerShell/AzPSLogin.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ export class AzPSLogin {
1515
core.info(`Running Azure PowerShell Login.`);
1616
AzPSUtils.setPSModulePathForGitHubRunner();
1717
await AzPSUtils.importLatestAzAccounts();
18-
const [loginMethod, loginScript] = await AzPSScriptBuilder.getAzPSLoginScript(this.loginConfig);
19-
core.info(`Attempting Azure PowerShell login by using ${loginMethod}...`);
20-
core.debug(`Azure PowerShell Login Script: ${loginScript}`);
21-
await AzPSUtils.runPSScript(loginScript);
18+
const { methodName, args, env } = await AzPSScriptBuilder.getAzPSLoginInvocation(this.loginConfig);
19+
core.info(`Attempting Azure PowerShell login by using ${methodName}...`);
20+
core.debug(`Azure PowerShell login invocation: pwsh ${JSON.stringify(args)}`);
21+
await AzPSUtils.runPSFile(args, env);
2222
console.log(`Running Azure PowerShell Login successfully.`);
2323
}
2424
}
Lines changed: 41 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1+
import * as path from 'path';
12
import { LoginConfig } from '../common/LoginConfig';
23

4+
export interface AzPSLoginInvocation {
5+
methodName: string;
6+
args: string[];
7+
env: Record<string, string>;
8+
}
9+
310
export default class AzPSScriptBuilder {
411

12+
static readonly ENV_SP_SECRET = 'AZURE_LOGIN_ACTION__SP_SECRET';
13+
static readonly ENV_FEDERATED_TOKEN = 'AZURE_LOGIN_ACTION__FEDERATED_TOKEN';
14+
15+
static getScriptPath(): string {
16+
return path.join(__dirname, 'AzPSLogin.ps1');
17+
}
18+
519
static getImportLatestModuleScript(moduleName: string): string {
620
let script = `try {
721
$ErrorActionPreference = "Stop"
@@ -21,103 +35,45 @@ export default class AzPSScriptBuilder {
2135
return script;
2236
}
2337

24-
// Doubles single quotes for safe interpolation into a PowerShell '...' literal.
25-
private static escapePSSingleQuoted(value: string): string {
26-
if (value === null || value === undefined) {
27-
return "";
38+
static async getAzPSLoginInvocation(loginConfig: LoginConfig): Promise<AzPSLoginInvocation> {
39+
const args: string[] = [
40+
'-File', AzPSScriptBuilder.getScriptPath(),
41+
'-Environment', loginConfig.environment,
42+
'-AuthType', loginConfig.authType,
43+
];
44+
const env: Record<string, string> = {};
45+
let methodName: string;
46+
47+
if (loginConfig.tenantId) {
48+
args.push('-Tenant', loginConfig.tenantId);
2849
}
29-
return String(value).split("'").join("''");
30-
}
31-
32-
static async getAzPSLoginScript(loginConfig: LoginConfig) {
33-
let loginMethodName = "";
34-
let commands = "";
35-
36-
if (loginConfig.environment.toLowerCase() == "azurestack") {
37-
commands += `Add-AzEnvironment -Name '${loginConfig.environment}' -ARMEndpoint '${AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.resourceManagerEndpointUrl)}' | out-null;`;
50+
if (loginConfig.subscriptionId) {
51+
args.push('-Subscription', loginConfig.subscriptionId);
3852
}
53+
if (loginConfig.environment.toLowerCase() === 'azurestack') {
54+
args.push('-ArmEndpoint', loginConfig.resourceManagerEndpointUrl);
55+
}
56+
3957
if (loginConfig.authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL) {
58+
args.push('-ApplicationId', loginConfig.servicePrincipalId);
4059
if (loginConfig.servicePrincipalSecret) {
41-
commands += AzPSScriptBuilder.loginWithSecret(loginConfig);
42-
loginMethodName = 'service principal with secret';
60+
env[AzPSScriptBuilder.ENV_SP_SECRET] = loginConfig.servicePrincipalSecret;
61+
methodName = 'service principal with secret';
4362
} else {
44-
commands += await AzPSScriptBuilder.loginWithOIDC(loginConfig);
45-
loginMethodName = "OIDC";
63+
await loginConfig.getFederatedToken();
64+
env[AzPSScriptBuilder.ENV_FEDERATED_TOKEN] = loginConfig.federatedToken;
65+
methodName = 'OIDC';
4666
}
4767
} else {
4868
if (loginConfig.servicePrincipalId) {
49-
commands += AzPSScriptBuilder.loginWithUserAssignedIdentity(loginConfig);
50-
loginMethodName = 'user-assigned managed identity';
69+
args.push('-ApplicationId', loginConfig.servicePrincipalId);
70+
methodName = 'user-assigned managed identity';
5171
} else {
52-
commands += AzPSScriptBuilder.loginWithSystemAssignedIdentity(loginConfig);
53-
loginMethodName = 'system-assigned managed identity';
72+
methodName = 'system-assigned managed identity';
5473
}
5574
}
5675

57-
let script = `try {
58-
$ErrorActionPreference = "Stop"
59-
$WarningPreference = "SilentlyContinue"
60-
$output = @{}
61-
${commands}
62-
$output['Success'] = $true
63-
$output['Result'] = ""
64-
}
65-
catch {
66-
$output['Success'] = $false
67-
$output['Error'] = $_.exception.Message
68-
}
69-
return ConvertTo-Json $output`;
70-
71-
return [loginMethodName, script];
72-
}
73-
74-
private static loginWithSecret(loginConfig: LoginConfig): string {
75-
let servicePrincipalSecret: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalSecret);
76-
let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId);
77-
let loginCmdlet = `$psLoginSecrets = ConvertTo-SecureString '${servicePrincipalSecret}' -AsPlainText -Force; `;
78-
loginCmdlet += `$psLoginCredential = New-Object System.Management.Automation.PSCredential('${servicePrincipalId}', $psLoginSecrets); `;
79-
80-
let cmdletSuffix = "-Credential $psLoginCredential";
81-
loginCmdlet += AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
82-
83-
return loginCmdlet;
84-
}
85-
86-
private static async loginWithOIDC(loginConfig: LoginConfig) {
87-
await loginConfig.getFederatedToken();
88-
let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId);
89-
let federatedToken: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.federatedToken);
90-
let cmdletSuffix = `-ApplicationId '${servicePrincipalId}' -FederatedToken '${federatedToken}'`;
91-
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
92-
}
93-
94-
private static loginWithSystemAssignedIdentity(loginConfig: LoginConfig): string {
95-
let cmdletSuffix = "";
96-
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
97-
}
98-
99-
static loginWithUserAssignedIdentity(loginConfig: LoginConfig): string {
100-
let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId);
101-
let cmdletSuffix = `-AccountId '${servicePrincipalId}'`;
102-
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
103-
}
104-
105-
private static psLoginCmdlet(authType:string, environment:string, tenantId:string, subscriptionId:string, cmdletSuffix:string){
106-
let loginCmdlet = `Connect-AzAccount `;
107-
if(authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL){
108-
loginCmdlet += "-ServicePrincipal ";
109-
}else{
110-
loginCmdlet += "-Identity ";
111-
}
112-
loginCmdlet += `-Environment '${environment}' `;
113-
if(tenantId){
114-
loginCmdlet += `-Tenant '${AzPSScriptBuilder.escapePSSingleQuoted(tenantId)}' `;
115-
}
116-
if(subscriptionId){
117-
loginCmdlet += `-Subscription '${AzPSScriptBuilder.escapePSSingleQuoted(subscriptionId)}' `;
118-
}
119-
loginCmdlet += `${cmdletSuffix} -InformationAction Ignore | out-null;`;
120-
return loginCmdlet;
76+
return { methodName, args, env };
12177
}
12278
}
12379

‎src/PowerShell/AzPSUtils.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export class AzPSUtils {
5252
}
5353

5454
static async runPSScript(psScript: string): Promise<string> {
55+
return AzPSUtils.runPwsh(['-Command', psScript]);
56+
}
57+
58+
static async runPSFile(args: string[], extraEnv: Record<string, string> = {}): Promise<string> {
59+
return AzPSUtils.runPwsh(args, extraEnv);
60+
}
61+
62+
private static async runPwsh(args: string[], extraEnv: Record<string, string> = {}): Promise<string> {
5563
let outputString: string = "";
5664
let commandStdErr = false;
5765
const options: any = {
@@ -69,9 +77,12 @@ export class AzPSUtils {
6977
}
7078
}
7179
};
80+
if (Object.keys(extraEnv).length > 0) {
81+
options.env = { ...process.env, ...extraEnv };
82+
}
7283

7384
let psPath: string = await io.which(AzPSConstants.PowerShell_CmdName, true);
74-
await exec.exec(`"${psPath}"`, ["-Command", psScript], options)
85+
await exec.exec(`"${psPath}"`, args, options)
7586
if (commandStdErr) {
7687
throw new Error('Azure PowerShell login failed with errors.');
7788
}

0 commit comments

Comments
 (0)