From 9b5e93813fc761db49db10caab17c9ebeaea2f08 Mon Sep 17 00:00:00 2001 From: yuriyryabikov <22548029+kurok@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:44:34 +0100 Subject: [PATCH] feat: opt-in EBS encryption for runner root volume (Phase 6.b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out Phase 6 (IMDSv2 landed in #24, EBS encryption deferred until a per-AMI root-device lookup could be done safely). ## Change New 'encrypt-ebs' input on action.yml, default 'false' (opt-in). When 'true', the action: 1. Fetches the AMI's DescribeImages result (already needed to resolve image IDs when 'ec2-image-filters' is set). 2. Finds the BlockDeviceMapping matching the AMI's RootDeviceName. 3. Clones that mapping, drops SnapshotId (AWS uses the AMI's snapshot automatically), sets 'Encrypted: true'. 4. Passes the cloned mapping as RunInstances.BlockDeviceMappings. Result: root volume launches with SSE-EBS, key 'alias/aws/ebs' in the launch account. VolumeSize / VolumeType / IOPS / DeleteOnTermination preserved from the AMI — only the encryption bit is new. ## Why opt-in The launch account (not necessarily the AMI owner account) must have either default EBS encryption enabled, or at minimum permission to use the default AWS-managed KMS key. If the AMI's snapshot is encrypted with a customer-managed key that doesn't have a cross- account grant, RunInstances fails. Defaulting to 'true' would regress every consumer whose IAM / KMS policy isn't set up for this. Default 'false' lets each consumer opt in after verifying their account can handle it. ## Why not account-level default encryption AWS supports 'aws ec2 enable-ebs-encryption-by-default' at the account level — and that's the preferred belt-and-suspenders. But not every consumer runs in an AWS account they control (e.g., Namecheap's CI runs in a shared org account). Action-side opt-in is the only portable control. ## Refactor alongside resolveImageId -> resolveImage: now returns both the ID and the full Image metadata. Callers that only need the ID use .id; the EBS-encryption code path uses .image.BlockDeviceMappings to build the encrypted clone. ## Tests tests/ebs.test.js — 6 new cases for buildEncryptedRootMapping: happy path with full EBS config + non-EBS sibling mapping, volume type / size / iops preservation, and five null-return paths for exotic AMI shapes (no RootDeviceName, no mappings, non-EBS root, orphan RootDeviceName). tests/config.test.js — 2 new cases for the encrypt-ebs input (default fallback + override). Total: 44 -> 52 tests. ## Consumer dogfood Separate PR on terraform-provider-namecheap rotates the pin and enables 'encrypt-ebs: true' on the CI job. If the dogfood fails with a KMS / IAM permissions error, we know the account needs policy work before enabling; action-side code is fine either way. Signed-off-by: yuriyryabikov <22548029+kurok@users.noreply.github.com> --- action.yml | 13 ++++++ dist/index.js | 62 +++++++++++++++++++++++++--- src/aws.js | 61 +++++++++++++++++++++++++--- src/config.js | 1 + tests/config.test.js | 12 ++++++ tests/ebs.test.js | 97 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 tests/ebs.test.js diff --git a/action.yml b/action.yml index 424ffa94..e5f43445 100644 --- a/action.yml +++ b/action.yml @@ -79,6 +79,19 @@ inputs: override, add the corresponding hash to the table in a PR. required: false default: '2.333.1' + encrypt-ebs: + description: >- + When 'true', the root EBS volume is created with SSE-EBS + encryption enabled (AWS-managed KMS key, 'alias/aws/ebs', in + the launch account). Requires that the account either has + default EBS encryption enabled or can use the default AWS- + managed KMS key. The AMI's BlockDeviceMapping is cloned and + patched with 'Encrypted: true'; volume size / type / IOPS + are preserved from the AMI. Default 'false' to avoid + regressing consumers whose IAM / KMS policy doesn't allow + this — opt in explicitly when you've verified the permissions. + required: false + default: 'false' http-tokens: description: >- Instance Metadata Service (IMDS) token mode. Accepted values: diff --git a/dist/index.js b/dist/index.js index cc7bcfe0..a05d3ed9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -87884,9 +87884,17 @@ async function waitForInstanceRunning(ec2InstanceId) { } } -async function resolveImageId(client) { +async function resolveImage(client) { + // Resolves both the image ID and the image's metadata (root-device + + // block-device mappings). Callers that only need the ID use the .id + // shortcut; the .image field is used by encrypt-ebs to clone the + // AMI's BlockDeviceMappings and layer SSE-EBS onto them. if (config.input.ec2ImageId) { - return config.input.ec2ImageId; + const direct = await client.send(new DescribeImagesCommand({ ImageIds: [config.input.ec2ImageId] })); + if (!direct.Images || direct.Images.length === 0) { + throw new Error(`Unable to describe AMI ${config.input.ec2ImageId}`); + } + return { id: config.input.ec2ImageId, image: direct.Images[0] }; } const amiParams = { @@ -87906,10 +87914,34 @@ async function resolveImageId(client) { throw new Error('Unable to find AMI using passed filter'); } sortByCreationDate(result); - const picked = result.Images[0].ImageId; - log.info('describe_images', { match_count: result.Images.length, selected_ami: picked }); + const picked = result.Images[0]; + log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId }); log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) }); - return picked; + return { id: picked.ImageId, image: picked }; +} + +// Build BlockDeviceMappings that encrypt the AMI's root volume without +// changing its size, type, or iops. Returns null when no root mapping +// is present on the image (exotic AMIs) — caller should skip encryption +// and log a warning rather than ship a broken RunInstances call. +function buildEncryptedRootMapping(image) { + const rootDev = image.RootDeviceName; + if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) { + return null; + } + const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev); + if (!rootMap || !rootMap.Ebs) { + return null; + } + // Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS + // will use the AMI's snapshot automatically and re-encrypt during + // launch under the account's default EBS key. + const ebsClone = { ...rootMap.Ebs }; + delete ebsClone.SnapshotId; + return [{ + DeviceName: rootDev, + Ebs: { ...ebsClone, Encrypted: true }, + }]; } async function startEc2Instance(label, githubRegistrationToken) { @@ -87999,7 +88031,8 @@ async function startEc2Instance(label, githubRegistrationToken) { '', ]; - config.input.ec2ImageId = await resolveImageId(client); + const resolved = await resolveImage(client); + config.input.ec2ImageId = resolved.id; const params = { ImageId: config.input.ec2ImageId, @@ -88022,6 +88055,20 @@ async function startEc2Instance(label, githubRegistrationToken) { }, }; + if (config.input.encryptEbs === 'true') { + const mappings = buildEncryptedRootMapping(resolved.image); + if (mappings) { + params.BlockDeviceMappings = mappings; + log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName }); + } else { + log.warn('encrypt_ebs', { + applied: false, + reason: 'ami has no root EBS block-device mapping — skipping encryption override', + ami_id: resolved.id, + }); + } + } + let ec2InstanceId; const runStart = Date.now(); log.info('run_instance', { @@ -88085,6 +88132,8 @@ module.exports = { startEc2Instance, terminateEc2Instance, waitForInstanceRunning, + // Exported for unit testing. + buildEncryptedRootMapping, }; @@ -88113,6 +88162,7 @@ class Config { iamRoleName: core.getInput('iam-role-name'), runnerVersion: core.getInput('runner-version') || '2.333.1', httpTokens: core.getInput('http-tokens') || 'required', + encryptEbs: core.getInput('encrypt-ebs') || 'false', debug: core.getInput('debug') || 'false', }; diff --git a/src/aws.js b/src/aws.js index cfa7ac06..3b8e821d 100644 --- a/src/aws.js +++ b/src/aws.js @@ -38,9 +38,17 @@ async function waitForInstanceRunning(ec2InstanceId) { } } -async function resolveImageId(client) { +async function resolveImage(client) { + // Resolves both the image ID and the image's metadata (root-device + + // block-device mappings). Callers that only need the ID use the .id + // shortcut; the .image field is used by encrypt-ebs to clone the + // AMI's BlockDeviceMappings and layer SSE-EBS onto them. if (config.input.ec2ImageId) { - return config.input.ec2ImageId; + const direct = await client.send(new DescribeImagesCommand({ ImageIds: [config.input.ec2ImageId] })); + if (!direct.Images || direct.Images.length === 0) { + throw new Error(`Unable to describe AMI ${config.input.ec2ImageId}`); + } + return { id: config.input.ec2ImageId, image: direct.Images[0] }; } const amiParams = { @@ -60,10 +68,34 @@ async function resolveImageId(client) { throw new Error('Unable to find AMI using passed filter'); } sortByCreationDate(result); - const picked = result.Images[0].ImageId; - log.info('describe_images', { match_count: result.Images.length, selected_ami: picked }); + const picked = result.Images[0]; + log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId }); log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) }); - return picked; + return { id: picked.ImageId, image: picked }; +} + +// Build BlockDeviceMappings that encrypt the AMI's root volume without +// changing its size, type, or iops. Returns null when no root mapping +// is present on the image (exotic AMIs) — caller should skip encryption +// and log a warning rather than ship a broken RunInstances call. +function buildEncryptedRootMapping(image) { + const rootDev = image.RootDeviceName; + if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) { + return null; + } + const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev); + if (!rootMap || !rootMap.Ebs) { + return null; + } + // Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS + // will use the AMI's snapshot automatically and re-encrypt during + // launch under the account's default EBS key. + const ebsClone = { ...rootMap.Ebs }; + delete ebsClone.SnapshotId; + return [{ + DeviceName: rootDev, + Ebs: { ...ebsClone, Encrypted: true }, + }]; } async function startEc2Instance(label, githubRegistrationToken) { @@ -153,7 +185,8 @@ async function startEc2Instance(label, githubRegistrationToken) { '', ]; - config.input.ec2ImageId = await resolveImageId(client); + const resolved = await resolveImage(client); + config.input.ec2ImageId = resolved.id; const params = { ImageId: config.input.ec2ImageId, @@ -176,6 +209,20 @@ async function startEc2Instance(label, githubRegistrationToken) { }, }; + if (config.input.encryptEbs === 'true') { + const mappings = buildEncryptedRootMapping(resolved.image); + if (mappings) { + params.BlockDeviceMappings = mappings; + log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName }); + } else { + log.warn('encrypt_ebs', { + applied: false, + reason: 'ami has no root EBS block-device mapping — skipping encryption override', + ami_id: resolved.id, + }); + } + } + let ec2InstanceId; const runStart = Date.now(); log.info('run_instance', { @@ -239,4 +286,6 @@ module.exports = { startEc2Instance, terminateEc2Instance, waitForInstanceRunning, + // Exported for unit testing. + buildEncryptedRootMapping, }; diff --git a/src/config.js b/src/config.js index 92df1a7b..c02c6d38 100644 --- a/src/config.js +++ b/src/config.js @@ -18,6 +18,7 @@ class Config { iamRoleName: core.getInput('iam-role-name'), runnerVersion: core.getInput('runner-version') || '2.333.1', httpTokens: core.getInput('http-tokens') || 'required', + encryptEbs: core.getInput('encrypt-ebs') || 'false', debug: core.getInput('debug') || 'false', }; diff --git a/tests/config.test.js b/tests/config.test.js index d9ea3ae6..17ab235f 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -143,6 +143,18 @@ describe('Config — runner-version input', () => { }); }); +describe('Config — encrypt-ebs input', () => { + test('defaults to "false" when unset', () => { + const config = loadConfig(startModeInputs); + expect(config.input.encryptEbs).toBe('false'); + }); + + test('honors "true"', () => { + const config = loadConfig({ ...startModeInputs, 'encrypt-ebs': 'true' }); + expect(config.input.encryptEbs).toBe('true'); + }); +}); + describe('Config — http-tokens input', () => { test('defaults to "required" when unset', () => { const config = loadConfig(startModeInputs); diff --git a/tests/ebs.test.js b/tests/ebs.test.js new file mode 100644 index 00000000..34f7c150 --- /dev/null +++ b/tests/ebs.test.js @@ -0,0 +1,97 @@ +// Tests for buildEncryptedRootMapping. The function is a pure transform +// of a DescribeImages response — no AWS/GitHub stubbing required. +// +// aws.js requires ./config at module load; mock it so the require chain +// resolves without a valid Config singleton (tests don't touch config- +// dependent code paths in this file). + +beforeAll(() => { + jest.doMock('../src/config', () => ({ + input: { mode: 'start', debug: 'false' }, + githubContext: { owner: 'o', repo: 'r' }, + tagSpecifications: null, + })); + jest.doMock('@actions/core', () => ({ + info: jest.fn(), warning: jest.fn(), error: jest.fn(), setFailed: jest.fn(), getInput: jest.fn(), + startGroup: jest.fn(), endGroup: jest.fn(), + })); +}); + +const { buildEncryptedRootMapping } = require('../src/aws'); + +describe('buildEncryptedRootMapping', () => { + test('clones the AMI root mapping and flips Encrypted to true', () => { + const image = { + RootDeviceName: '/dev/xvda', + BlockDeviceMappings: [ + { + DeviceName: '/dev/xvda', + Ebs: { + SnapshotId: 'snap-abc', + VolumeSize: 30, + VolumeType: 'gp3', + Iops: 3000, + DeleteOnTermination: true, + }, + }, + { DeviceName: '/dev/sdb', VirtualName: 'ephemeral0' }, // non-EBS, should be ignored + ], + }; + + const result = buildEncryptedRootMapping(image); + + expect(result).toEqual([{ + DeviceName: '/dev/xvda', + Ebs: { + VolumeSize: 30, + VolumeType: 'gp3', + Iops: 3000, + DeleteOnTermination: true, + Encrypted: true, + }, + }]); + // SnapshotId must be dropped (AWS uses the AMI's snapshot automatically). + expect(result[0].Ebs.SnapshotId).toBeUndefined(); + }); + + test('preserves volume type + size + IOPS untouched', () => { + const image = { + RootDeviceName: '/dev/sda1', + BlockDeviceMappings: [{ + DeviceName: '/dev/sda1', + Ebs: { VolumeSize: 100, VolumeType: 'io2', Iops: 10000 }, + }], + }; + + const result = buildEncryptedRootMapping(image); + + expect(result[0].Ebs.VolumeSize).toBe(100); + expect(result[0].Ebs.VolumeType).toBe('io2'); + expect(result[0].Ebs.Iops).toBe(10000); + expect(result[0].Ebs.Encrypted).toBe(true); + }); + + test('returns null when the AMI has no root device name', () => { + expect(buildEncryptedRootMapping({ BlockDeviceMappings: [] })).toBeNull(); + }); + + test('returns null when the AMI has no BlockDeviceMappings', () => { + expect(buildEncryptedRootMapping({ RootDeviceName: '/dev/xvda' })).toBeNull(); + }); + + test('returns null when the root mapping has no Ebs sub-object', () => { + const image = { + RootDeviceName: '/dev/xvda', + BlockDeviceMappings: [{ DeviceName: '/dev/xvda', VirtualName: 'ephemeral0' }], + }; + expect(buildEncryptedRootMapping(image)).toBeNull(); + }); + + test('returns null when RootDeviceName points to a mapping that doesn\'t exist', () => { + const image = { + RootDeviceName: '/dev/xvda', + BlockDeviceMappings: [{ DeviceName: '/dev/sdb', Ebs: { VolumeSize: 10 } }], + }; + expect(buildEncryptedRootMapping(image)).toBeNull(); + }); +});