mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-08-23 04:15:08 +09:00
fix: enforce allowed-account-ids on all auth paths (#1847)
* fix: enforce allowed-account-ids on all auth paths The allowed-account-ids list was only enforced in some auth flows. This was due to the check being included in validateCredentials, which was skipped if (GITHUB_ACTIONS && AccessKeyId && output-env-credentials) -> false. This unifies credential validation into a single path. - validateCredentials(credentials?, ...) resolves credentials, proves liveness via one GetCallerIdentity call, and returns the identity. - validateAccountId(expectedAccountIds, account) is now a pure comparison against the resolved account, enforced against the final (assumed) account independent of auth method, GITHUB_ACTIONS, or output-env-credentials. - exportAccountId(identity, ...) consumes the resolved identity instead of making its own GetCallerIdentity call, so credential resolution happens exactly once per credential set. Pre-assume account checks remain gated on !roleToAssume so cross-account assume-role (source account differs from the role's target) is preserved. Adds regression tests for the OIDC wrong-account case (the previously missing negative test), OIDC with output-env-credentials: false, and the assume-role wrong-account case. * chore: move validateAccountId into helpers
This commit is contained in:
@@ -776,7 +776,10 @@ the environment (for example, on a self-hosted runner where you do not want the
|
|||||||
assumed-role credentials to shadow an existing EC2 instance profile), pair
|
assumed-role credentials to shadow an existing EC2 instance profile), pair
|
||||||
`output-credentials: true` with `output-env-credentials: false`. In that mode,
|
`output-credentials: true` with `output-env-credentials: false`. In that mode,
|
||||||
the action does not run its post-credential SDK-pickup validation step, since
|
the action does not run its post-credential SDK-pickup validation step, since
|
||||||
the credentials were never written to the environment.
|
the credentials were never written to the environment. The action still
|
||||||
|
validates the resolved credentials by calling `sts:GetCallerIdentity` with the
|
||||||
|
explicit credentials, so the `allowed-account-ids` check can be enforced if
|
||||||
|
provided.
|
||||||
|
|
||||||
### Configure multiple AWS profiles in a single workflow
|
### Configure multiple AWS profiles in a single workflow
|
||||||
|
|
||||||
|
|||||||
+34
-33
@@ -53,54 +53,55 @@ export class CredentialsClient {
|
|||||||
|
|
||||||
public get stsClient(): STSClient {
|
public get stsClient(): STSClient {
|
||||||
if (!this._stsClient || this.roleChaining) {
|
if (!this._stsClient || this.roleChaining) {
|
||||||
this._stsClient = new STSClient({
|
this._stsClient = this.createStsClient();
|
||||||
customUserAgent: buildCustomUserAgent(),
|
|
||||||
...(this.region !== undefined && { region: this.region }),
|
|
||||||
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
|
|
||||||
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return this._stsClient;
|
return this._stsClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builds an STS client using the action's configured region/endpoint/proxy. When explicit credentials are provided,
|
||||||
|
// the client uses them directly instead of the SDK default credential provider chain.
|
||||||
|
// This matters for validateAccountId.
|
||||||
|
private createStsClient(credentials?: AwsCredentialIdentity): STSClient {
|
||||||
|
return new STSClient({
|
||||||
|
customUserAgent: buildCustomUserAgent(),
|
||||||
|
...(this.region !== undefined && { region: this.region }),
|
||||||
|
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
|
||||||
|
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
|
||||||
|
...(credentials !== undefined && { credentials }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates that the credentials the action will hand to subsequent steps actually work, and returns the resolved
|
||||||
|
// caller identity (account + ARN). "Work" is proven by a sts:GetCallerIdentity call, which both confirms the
|
||||||
|
// credentials are accepted by AWS and returns the identity for later checks and outputs to use.
|
||||||
public async validateCredentials(
|
public async validateCredentials(
|
||||||
|
credentials?: AwsCredentialIdentity,
|
||||||
expectedAccessKeyId?: string,
|
expectedAccessKeyId?: string,
|
||||||
roleChaining?: boolean,
|
roleChaining?: boolean,
|
||||||
expectedAccountIds?: string[],
|
): Promise<Awaited<ReturnType<typeof getCallerIdentity>>> {
|
||||||
) {
|
if (!credentials) {
|
||||||
let credentials: AwsCredentialIdentity;
|
let resolved: AwsCredentialIdentity;
|
||||||
try {
|
|
||||||
credentials = await this.loadCredentials();
|
|
||||||
if (!credentials.accessKeyId) {
|
|
||||||
throw new Error('Access key ID empty after loading credentials');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
|
||||||
}
|
|
||||||
if (expectedAccountIds && expectedAccountIds.length > 0 && expectedAccountIds[0] !== '') {
|
|
||||||
let callerIdentity: Awaited<ReturnType<typeof getCallerIdentity>>;
|
|
||||||
try {
|
try {
|
||||||
callerIdentity = await getCallerIdentity(this.stsClient);
|
resolved = await this.loadCredentials();
|
||||||
|
if (!resolved.accessKeyId) {
|
||||||
|
throw new Error('Access key ID empty after loading credentials');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Could not validate account ID of credentials: ${errorMessage(error)}`);
|
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||||
}
|
}
|
||||||
if (!callerIdentity.Account || !expectedAccountIds.includes(callerIdentity.Account)) {
|
if (!roleChaining && expectedAccessKeyId && expectedAccessKeyId !== resolved.accessKeyId) {
|
||||||
throw new Error(
|
|
||||||
`The account ID of the provided credentials (${
|
|
||||||
callerIdentity.Account ?? 'unknown'
|
|
||||||
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!roleChaining) {
|
|
||||||
const actualAccessKeyId = credentials.accessKeyId;
|
|
||||||
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
|
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const client = credentials ? this.createStsClient(credentials) : this.stsClient;
|
||||||
|
try {
|
||||||
|
return await getCallerIdentity(client);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCredentials() {
|
private async loadCredentials() {
|
||||||
|
|||||||
+32
-3
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
|
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
|
||||||
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
||||||
|
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||||
import type { UserAgent } from '@smithy/types';
|
import type { UserAgent } from '@smithy/types';
|
||||||
import type { CredentialsClient } from './CredentialsClient';
|
import type { CredentialsClient } from './CredentialsClient';
|
||||||
|
|
||||||
@@ -150,9 +151,8 @@ export async function getCallerIdentity(client: STSClient): Promise<{ Account: s
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtains account ID from STS Client and sets it as output
|
// Emits the account ID and ARN of an already-resolved caller identity as action outputs.
|
||||||
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: boolean) {
|
export function exportAccountId(identity: { Account: string; Arn: string }, maskAccountId?: boolean) {
|
||||||
const identity = await getCallerIdentity(credentialsClient.stsClient);
|
|
||||||
const accountId = identity.Account;
|
const accountId = identity.Account;
|
||||||
const arn = identity.Arn;
|
const arn = identity.Arn;
|
||||||
if (maskAccountId) {
|
if (maskAccountId) {
|
||||||
@@ -164,6 +164,35 @@ export async function exportAccountId(credentialsClient: CredentialsClient, mask
|
|||||||
return accountId;
|
return accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validates that the account of the already-resolved caller identity is in the allow-list provided via the
|
||||||
|
// `allowed-account-ids` input.
|
||||||
|
export function validateAccountId(expectedAccountIds: string[] | undefined, account: string | undefined): void {
|
||||||
|
if (!expectedAccountIds || expectedAccountIds.length === 0 || expectedAccountIds[0] === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!account || !expectedAccountIds.includes(account)) {
|
||||||
|
throw new Error(
|
||||||
|
`The account ID of the provided credentials (${
|
||||||
|
account ?? 'unknown'
|
||||||
|
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts the STS Credentials shape (returned by AssumeRole and provided as action inputs) into
|
||||||
|
// the AwsCredentialIdentity shape the SDK expects when credentials are supplied explicitly to a
|
||||||
|
// client. Returns undefined if the access key ID or secret access key is missing.
|
||||||
|
export function toCredentialIdentity(creds?: Partial<Credentials>): AwsCredentialIdentity | undefined {
|
||||||
|
if (!creds?.AccessKeyId || !creds.SecretAccessKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessKeyId: creds.AccessKeyId,
|
||||||
|
secretAccessKey: creds.SecretAccessKey,
|
||||||
|
...(creds.SessionToken && { sessionToken: creds.SessionToken }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Tags have a more restrictive set of acceptable characters than GitHub environment variables can.
|
// Tags have a more restrictive set of acceptable characters than GitHub environment variables can.
|
||||||
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
|
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
|
||||||
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
|
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
|
||||||
|
|||||||
+38
-44
@@ -10,8 +10,10 @@ import {
|
|||||||
exportRegion,
|
exportRegion,
|
||||||
getBooleanInput,
|
getBooleanInput,
|
||||||
retryAndBackoff,
|
retryAndBackoff,
|
||||||
|
toCredentialIdentity,
|
||||||
translateEnvVariables,
|
translateEnvVariables,
|
||||||
unsetCredentials,
|
unsetCredentials,
|
||||||
|
validateAccountId,
|
||||||
verifyKeys,
|
verifyKeys,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import { writeProfileFiles } from './profileManager';
|
import { writeProfileFiles } from './profileManager';
|
||||||
@@ -51,8 +53,8 @@ export async function run() {
|
|||||||
});
|
});
|
||||||
const roleChaining = getBooleanInput('role-chaining', { required: false });
|
const roleChaining = getBooleanInput('role-chaining', { required: false });
|
||||||
const outputCredentials = getBooleanInput('output-credentials', { required: false });
|
const outputCredentials = getBooleanInput('output-credentials', { required: false });
|
||||||
// Default to always outputting environment credentials unless profile is specified. If profile is specified, default to
|
// Default to always outputting environment credentials unless profile is specified. If profile is specified, default
|
||||||
// no environment credentials (but still output them if the user specifically requests it).
|
// to no environment credentials (but still output them if the user specifically requests it).
|
||||||
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: !awsProfile });
|
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: !awsProfile });
|
||||||
const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false });
|
const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false });
|
||||||
let disableRetry = getBooleanInput('disable-retry', { required: false });
|
let disableRetry = getBooleanInput('disable-retry', { required: false });
|
||||||
@@ -198,27 +200,38 @@ export async function run() {
|
|||||||
writeProfileFiles(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region, overwriteAwsProfile);
|
writeProfileFiles(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region, overwriteAwsProfile);
|
||||||
}
|
}
|
||||||
} else if (!webIdentityTokenFile && !roleChaining) {
|
} else if (!webIdentityTokenFile && !roleChaining) {
|
||||||
// Proceed only if credentials can be picked up
|
// Proceed only if credentials can be picked up. validateCredentials resolves the ambient
|
||||||
await withRetry(
|
// credentials via the SDK default chain, proves they work, and returns the caller identity.
|
||||||
() => credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds),
|
const identity = await withRetry(
|
||||||
|
() => credentialsClient.validateCredentials(undefined, undefined, roleChaining),
|
||||||
'validateCredentials',
|
'validateCredentials',
|
||||||
);
|
);
|
||||||
sourceAccountId = await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
|
// Enforce the allowed-account-ids guardrail unless a role will be assumed, in which case the
|
||||||
|
// final account is validated after assumeRole (these ambient credentials are the source account).
|
||||||
|
if (!roleToAssume) {
|
||||||
|
validateAccountId(expectedAccountIds, identity.Account);
|
||||||
|
}
|
||||||
|
sourceAccountId = exportAccountId(identity, maskAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AccessKeyId || roleChaining) {
|
if (AccessKeyId || roleChaining) {
|
||||||
// Validate that the SDK can actually pick up credentials.
|
// Validate that the credentials the action will use actually work, and resolve their identity.
|
||||||
// This validates cases where this action is using existing environment credentials,
|
const resolutionCredentials =
|
||||||
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
|
outputEnvCredentials || !AccessKeyId
|
||||||
// Skip when output-env-credentials is false: input IAM keys were not written to env, so
|
? undefined
|
||||||
// the default chain would resolve to ambient runner credentials and the access-key check
|
: toCredentialIdentity({ AccessKeyId, SecretAccessKey, SessionToken });
|
||||||
// would spuriously fail (see #1554).
|
const identity = await withRetry(
|
||||||
|
() => credentialsClient.validateCredentials(resolutionCredentials, AccessKeyId, roleChaining),
|
||||||
|
'validateCredentials',
|
||||||
|
);
|
||||||
|
// Enforce the allowed-account-ids guardrail unless a role will be assumed (the final account is
|
||||||
|
// validated after assumeRole; these are the source credentials).
|
||||||
|
if (!roleToAssume) {
|
||||||
|
validateAccountId(expectedAccountIds, identity.Account);
|
||||||
|
}
|
||||||
|
sourceAccountId = identity.Account;
|
||||||
if (outputEnvCredentials) {
|
if (outputEnvCredentials) {
|
||||||
await withRetry(
|
exportAccountId(identity, maskAccountId);
|
||||||
() => credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds),
|
|
||||||
'validateCredentials',
|
|
||||||
);
|
|
||||||
sourceAccountId = await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
|
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
|
||||||
@@ -252,24 +265,15 @@ export async function run() {
|
|||||||
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
|
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
|
||||||
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
|
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
|
||||||
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
|
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
|
||||||
// Validate that the SDK can pick up the assumed-role credentials from the environment.
|
// Validate the assumed-role credentials and resolve their identity.
|
||||||
// Skip when output-env-credentials is false: the credentials were never written to env,
|
const identity = await withRetry(
|
||||||
// so the default credential provider chain would resolve to ambient runner credentials
|
() => credentialsClient.validateCredentials(toCredentialIdentity(roleCredentials.Credentials)),
|
||||||
// (e.g. an EC2 instance profile) and the access-key-id check would spuriously fail.
|
'validateCredentials',
|
||||||
// Skip when using a profile: validation runs after the profile file is written below.
|
);
|
||||||
if ((!process.env.GITHUB_ACTIONS || AccessKeyId) && !awsProfile && outputEnvCredentials) {
|
// Enforce the allowed-account-ids guardrail against the assumed (final) account.
|
||||||
await withRetry(
|
validateAccountId(expectedAccountIds, identity.Account);
|
||||||
() =>
|
|
||||||
credentialsClient.validateCredentials(
|
|
||||||
roleCredentials.Credentials?.AccessKeyId,
|
|
||||||
roleChaining,
|
|
||||||
expectedAccountIds,
|
|
||||||
),
|
|
||||||
'validateCredentials',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (outputEnvCredentials) {
|
if (outputEnvCredentials) {
|
||||||
await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
|
exportAccountId(identity, maskAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write profile files if profile mode is enabled
|
// Write profile files if profile mode is enabled
|
||||||
@@ -279,18 +283,8 @@ export async function run() {
|
|||||||
}
|
}
|
||||||
// If user provided IAM User Credentials and then we assumed a role, overwrite the profile file to add
|
// If user provided IAM User Credentials and then we assumed a role, overwrite the profile file to add
|
||||||
// the session token. (this only overwrites the profile within a single run of the action).
|
// the session token. (this only overwrites the profile within a single run of the action).
|
||||||
// We then validate the credentials to make sure they work.
|
|
||||||
if (AccessKeyId || !process.env.GITHUB_ACTIONS) {
|
if (AccessKeyId || !process.env.GITHUB_ACTIONS) {
|
||||||
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true);
|
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true);
|
||||||
await withRetry(
|
|
||||||
() =>
|
|
||||||
credentialsClient.validateCredentials(
|
|
||||||
roleCredentials.Credentials?.AccessKeyId,
|
|
||||||
roleChaining,
|
|
||||||
expectedAccountIds,
|
|
||||||
),
|
|
||||||
'validateCredentials',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile);
|
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-5
@@ -618,6 +618,9 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
});
|
});
|
||||||
it("doesn't export credentials as environment variables if told not to", {}, async () => {
|
it("doesn't export credentials as environment variables if told not to", {}, async () => {
|
||||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
// Credentials are validated (and their account resolved) even when not exported to the
|
||||||
|
// environment, so GetCallerIdentity is now called on the explicit assumed-role credentials.
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.NO_ENV_CREDS_INPUTS));
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.NO_ENV_CREDS_INPUTS));
|
||||||
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
@@ -628,6 +631,7 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
});
|
});
|
||||||
it('can export creds as step outputs without exporting as env variables', {}, async () => {
|
it('can export creds as step outputs without exporting as env variables', {}, async () => {
|
||||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.STEP_BUT_NO_ENV_INPUTS));
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.STEP_BUT_NO_ENV_INPUTS));
|
||||||
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
@@ -897,6 +901,65 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails with OIDC when account ID does not match allowed list', async () => {
|
||||||
|
// Regression test for the allowed-account-ids bypass: in a real runner (GITHUB_ACTIONS=true)
|
||||||
|
// authenticating via OIDC, the account-ID guardrail was previously never enforced.
|
||||||
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
|
mocks.getInput({
|
||||||
|
...mocks.GH_OIDC_INPUTS,
|
||||||
|
'allowed-account-ids': '999999999999',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
|
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with OIDC and output-env-credentials false when account ID does not match', async () => {
|
||||||
|
// The guardrail must hold even when credentials are never written to the environment.
|
||||||
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
|
mocks.getInput({
|
||||||
|
...mocks.NO_ENV_CREDS_INPUTS,
|
||||||
|
'allowed-account-ids': '999999999999',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
|
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with assume role when assumed account ID does not match allowed list', async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
|
mocks.getInput({
|
||||||
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
||||||
|
'allowed-account-ids': '999999999999',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||||
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
||||||
|
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
||||||
|
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
||||||
|
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('handles GetCallerIdentity API failure gracefully', async () => {
|
it('handles GetCallerIdentity API failure gracefully', async () => {
|
||||||
vi.mocked(core.getInput).mockImplementation(
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
mocks.getInput({
|
mocks.getInput({
|
||||||
@@ -911,7 +974,11 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await run();
|
await run();
|
||||||
expect(core.setFailed).toHaveBeenCalledWith('Could not validate account ID of credentials: API Error');
|
// The account allow-list now reuses the single liveness GetCallerIdentity call, so an STS
|
||||||
|
// failure surfaces as a credential-loading failure rather than a dedicated account-check error.
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
'Credentials could not be loaded, please check your action inputs: API Error',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores validation when allowed-account-ids is empty', async () => {
|
it('ignores validation when allowed-account-ids is empty', async () => {
|
||||||
@@ -1373,7 +1440,7 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Retry Behavior', {}, () => {
|
describe('Retry Behavior', {}, () => {
|
||||||
it('retries exportAccountId on transient GetCallerIdentity failure', async () => {
|
it('retries validateCredentials on transient GetCallerIdentity failure', async () => {
|
||||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||||
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
||||||
@@ -1384,7 +1451,9 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
.rejectsOnce(new Error('throttled'))
|
.rejectsOnce(new Error('throttled'))
|
||||||
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
await run();
|
await run();
|
||||||
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
|
// The single liveness GetCallerIdentity call lives in validateCredentials, so transient STS
|
||||||
|
// failures are retried under that label (the account ID is then resolved without a second call).
|
||||||
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry validateCredentials'));
|
||||||
expect(core.setFailed).not.toHaveBeenCalled();
|
expect(core.setFailed).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1414,7 +1483,7 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Retry'));
|
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Retry'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries exportAccountId after role assumption (issue #1681)', async () => {
|
it('retries the post-assume identity check on a transient invalid-token error (issue #1681)', async () => {
|
||||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
||||||
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||||
@@ -1424,7 +1493,9 @@ describe('Configure AWS Credentials', {}, () => {
|
|||||||
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
await run();
|
await run();
|
||||||
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
|
// Freshly-assumed credentials can be briefly rejected by STS (eventual consistency). The
|
||||||
|
// liveness GetCallerIdentity now runs inside validateCredentials, so the retry happens there.
|
||||||
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry validateCredentials'));
|
||||||
expect(core.info).toHaveBeenCalledWith(
|
expect(core.info).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('The security token included in the request is invalid'),
|
expect.stringContaining('The security token included in the request is invalid'),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user