From 3efe0738ef46d0b125db05b78e68997a55e78804 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Tue, 12 May 2026 17:56:15 -0700 Subject: [PATCH 01/19] feat(validation): add FIPS provider validation to Azl3 FIPS scenario tests --- e2e/scenario_test.go | 8 ++++++-- e2e/validators.go | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index 1ff774cf356..55525b31f75 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -26,7 +26,9 @@ func Test_AzureLinux3OSGuard(t *testing.T) { BootstrapConfigMutator: func(_ *Cluster, nbc *datamodel.NodeBootstrappingConfiguration) { nbc.AgentPoolProfile.LocalDNSProfile = nil }, - Validator: func(ctx context.Context, s *Scenario) {}, + Validator: func(ctx context.Context, s *Scenario) { + ValidateFIPSProvider(ctx, s) + }, VMConfigMutator: func(vmss *armcompute.VirtualMachineScaleSet) { vmss.Properties = addTrustedLaunchToVMSS(vmss.Properties) }, @@ -2698,7 +2700,9 @@ func Test_AzureLinux3OSGuard_PMC_Install(t *testing.T) { BootstrapConfigMutator: func(_ *Cluster, nbc *datamodel.NodeBootstrappingConfiguration) { nbc.AgentPoolProfile.LocalDNSProfile = nil }, - Validator: func(ctx context.Context, s *Scenario) {}, + Validator: func(ctx context.Context, s *Scenario) { + ValidateFIPSProvider(ctx, s) + }, VMConfigMutator: func(vmss *armcompute.VirtualMachineScaleSet) { vmss.Properties = addTrustedLaunchToVMSS(vmss.Properties) if vmss.Tags == nil { diff --git a/e2e/validators.go b/e2e/validators.go index 96597ada092..65b8123e0c3 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -653,6 +653,32 @@ func ValidateFileExcludesExactContent(ctx context.Context, s *Scenario, fileName } } +// ValidateFIPSProvider verifies that FIPS is properly configured on the node: +// 1. Kernel FIPS mode is enabled (/proc/sys/crypto/fips_enabled == 1). +// 2. OpenSSL has an active FIPS or SymCrypt provider loaded. +// 3. /opt/cni/bin/portmap runs without panicking (regression guard for ICM 51000001009688 +// where the OpenSSL FIPS provider was not loaded on AzureLinux V3 FIPS nodes). +func ValidateFIPSProvider(ctx context.Context, s *Scenario) { + s.T.Helper() + + // 1. Kernel FIPS mode. + fipsEnabled := execScriptOnVMForScenarioValidateExitCode(ctx, s, "cat /proc/sys/crypto/fips_enabled", 0, "could not read /proc/sys/crypto/fips_enabled") + require.Equal(s.T, "1", strings.TrimSpace(fipsEnabled.stdout), "expected /proc/sys/crypto/fips_enabled to be 1, got %q", fipsEnabled.stdout) + + // 2. OpenSSL provider must include an active fips or symcrypt provider. + providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") + require.Regexp(s.T, `(?s)(fips|symcrypt).*?status:\s*active`, providers.stdout, + "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) + + // 3. portmap must not panic. We exec it with no stdin/args; it will exit non-zero, + // but the output must not contain a Go panic stack trace. + portmap := execScriptOnVMForScenarioValidateExitCode(ctx, s, "/opt/cni/bin/portmap < /dev/null 2>&1 || true", 0, "could not run portmap") + combined := portmap.stdout + portmap.stderr + require.NotContains(s.T, combined, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\n%s", combined) + + s.T.Logf("FIPS provider validation passed") +} + func ServiceCanRestartValidator(ctx context.Context, s *Scenario, serviceName string, restartTimeoutInSeconds int) { s.T.Helper() steps := []string{ From 53d076c6f7fa8489d20b849f5aa7b2d70d062063 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Tue, 12 May 2026 18:20:32 -0700 Subject: [PATCH 02/19] feat(validation): add FIPS provider validation to ACLGen2 FIPS test --- e2e/scenario_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index 55525b31f75..433545c929b 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -262,6 +262,7 @@ func Test_ACLGen2FIPSTL(t *testing.T) { ValidateFileHasContent(ctx, s, "/etc/os-release", "ID=azurelinux") ValidateFileHasContent(ctx, s, "/etc/os-release", "VARIANT_ID=azurecontainerlinux") ValidateACLFIPSEnabled(ctx, s) + ValidateFIPSProvider(ctx, s) }, }, }) From 4384a92e4c7c009c3571c3ad9fab20cc42aaa1c6 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Tue, 12 May 2026 18:56:23 -0700 Subject: [PATCH 03/19] feat(validation): add Azure Linux V3 Gen2 FIPS support and validation tests --- e2e/config/vhd.go | 10 ++++++++ e2e/scenario_test.go | 22 +++++++++++++++++ .../packer/test/linux-vhd-content-test.sh | 24 +++++++++++++++++-- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/e2e/config/vhd.go b/e2e/config/vhd.go index 621f2f82287..451244b7f7f 100644 --- a/e2e/config/vhd.go +++ b/e2e/config/vhd.go @@ -127,6 +127,16 @@ var ( // Secure TLS Bootstrapping isn't currently supported on FIPS-enabled VHDs UnsupportedSecureTLSBootstrapping: true, } + VHDAzureLinuxV3Gen2FIPS = &Image{ + Name: "AzureLinuxV3gen2fips", + OS: OSAzureLinux, + Arch: "amd64", + Distro: datamodel.AKSAzureLinuxV3Gen2FIPS, + Gallery: imageGalleryLinux, + UnsupportedLocalDns: true, + // Secure TLS Bootstrapping isn't currently supported on FIPS-enabled VHDs + UnsupportedSecureTLSBootstrapping: true, + } // this is a particular 2204gen2containerd image originally built with private packages, // if we ever want to update this then we'd need to run a new VHD build using private package overrides VHDUbuntu2204Gen2ContainerdPrivateKubePkg = &Image{ diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index 433545c929b..c961f3aea68 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -268,6 +268,28 @@ func Test_ACLGen2FIPSTL(t *testing.T) { }) } +func Test_AzureLinuxV3Gen2FIPS(t *testing.T) { + RunScenario(t, &Scenario{ + Description: "Tests that a node using the Azure Linux V3 Gen2 FIPS VHD can be properly bootstrapped and FIPS is active at runtime", + Config: Config{ + Cluster: ClusterKubenet, + VHD: config.VHDAzureLinuxV3Gen2FIPS, + BootstrapConfigMutator: func(_ *Cluster, nbc *datamodel.NodeBootstrappingConfiguration) { + // LocalDNS isn't currently supported on FIPS-enabled VHDs. + nbc.AgentPoolProfile.LocalDNSProfile = nil + }, + VMConfigMutator: func(vmss *armcompute.VirtualMachineScaleSet) { + vmss.Properties.AdditionalCapabilities = &armcompute.AdditionalCapabilities{ + EnableFips1403Encryption: to.Ptr(true), + } + }, + Validator: func(ctx context.Context, s *Scenario) { + ValidateFIPSProvider(ctx, s) + }, + }, + }) +} + func Test_ACL_Scriptless(t *testing.T) { RunScenario(t, &Scenario{ Description: "Tests that a node using ACL and the self-contained installer can be properly bootstrapped", diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index 9d538ec7c06..5f9f88df82e 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -595,8 +595,7 @@ testFips() { enable_fips=$2 # shellcheck disable=SC3010 - if [[ (${os_version} == "20.04" || ${os_version} == "22.04" || ${os_version} == "V2" || ${os_version} == "acl") && ${enable_fips,,} == "true" ]]; then - kernel=$(uname -r) + if [[ ${enable_fips,,} == "true" ]]; then if [ -f /proc/sys/crypto/fips_enabled ]; then fips_enabled=$(cat /proc/sys/crypto/fips_enabled) if [ "${fips_enabled}" = "1" ]; then @@ -609,6 +608,7 @@ testFips() { fi if [ ${os_version} = "20.04" ]; then + kernel=$(uname -r) if [ -f /usr/src/linux-headers-${kernel}/Makefile ]; then echo "fips header files exist." else @@ -628,6 +628,26 @@ testFips() { err $test "ACL FIPS UKI addon file does not exist in active ESP location." fi fi + + # Verify OpenSSL has a FIPS or SymCrypt provider loaded and active. + # This caught the AzureLinux V3 FIPS regression (ICM 51000001009688) where + # the kernel FIPS flag was set but the OpenSSL provider was missing, + # causing /opt/cni/bin/portmap to panic at runtime. + providers_output=$(openssl list -providers 2>&1) + echo "openssl list -providers output:" + echo "${providers_output}" + # shellcheck disable=SC3010 + if [[ "${providers_output}" =~ (fips|symcrypt) ]]; then + echo "openssl FIPS/SymCrypt provider is registered." + else + err $test "openssl does not have a fips or symcrypt provider registered." + fi + # shellcheck disable=SC3010 + if [[ "${providers_output}" =~ status:[[:space:]]+active ]]; then + echo "openssl FIPS/SymCrypt provider has status: active." + else + err $test "openssl FIPS/SymCrypt provider is not active." + fi fi echo "$test:Finish" From 01af0028d8cd3f6d7dc5cde2eab60e7fbb11fae3 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Tue, 12 May 2026 19:04:53 -0700 Subject: [PATCH 04/19] feat(validation): enhance FIPS provider validation for OpenSSL 3.x on supported OS variants --- .../packer/test/linux-vhd-content-test.sh | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index 5f9f88df82e..19d52b246b6 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -633,20 +633,29 @@ testFips() { # This caught the AzureLinux V3 FIPS regression (ICM 51000001009688) where # the kernel FIPS flag was set but the OpenSSL provider was missing, # causing /opt/cni/bin/portmap to panic at runtime. - providers_output=$(openssl list -providers 2>&1) - echo "openssl list -providers output:" - echo "${providers_output}" + # + # Only run on OS variants that ship OpenSSL 3.x (the providers concept + # doesn't exist in OpenSSL 1.1.x). Ubuntu 20.04 and AzureLinux V2 ship + # OpenSSL 1.1.x and use the legacy FIPS module model. # shellcheck disable=SC3010 - if [[ "${providers_output}" =~ (fips|symcrypt) ]]; then - echo "openssl FIPS/SymCrypt provider is registered." - else - err $test "openssl does not have a fips or symcrypt provider registered." - fi - # shellcheck disable=SC3010 - if [[ "${providers_output}" =~ status:[[:space:]]+active ]]; then - echo "openssl FIPS/SymCrypt provider has status: active." + if [[ ${os_version} == "22.04" || ${os_version} == "24.04" || ${os_version} == "V3" || ${os_version} == "OSGuardV3" || ${os_version} == "acl" ]]; then + providers_output=$(openssl list -providers 2>&1) + echo "openssl list -providers output:" + echo "${providers_output}" + # shellcheck disable=SC3010 + if [[ "${providers_output}" =~ (fips|symcrypt) ]]; then + echo "openssl FIPS/SymCrypt provider is registered." + else + err $test "openssl does not have a fips or symcrypt provider registered." + fi + # shellcheck disable=SC3010 + if [[ "${providers_output}" =~ status:[[:space:]]+active ]]; then + echo "openssl FIPS/SymCrypt provider has status: active." + else + err $test "openssl FIPS/SymCrypt provider is not active." + fi else - err $test "openssl FIPS/SymCrypt provider is not active." + echo "openssl providers check skipped: ${os_version} ships OpenSSL 1.1.x (legacy FIPS module)." fi fi From 3cc3ac2e5f30f0c9bf9e43d930b988772aa63490 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Wed, 13 May 2026 15:01:01 -0700 Subject: [PATCH 05/19] feat(validation): enhance FIPS provider validation in tests and scripts for OpenSSL 3.x --- e2e/scenario_test.go | 3 + e2e/validators.go | 59 ++++++++++++++++--- .../packer/test/linux-vhd-content-test.sh | 32 ++++++---- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index c961f3aea68..cabb0aa7af1 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -793,6 +793,7 @@ func Test_Ubuntu2204FIPS(t *testing.T) { ValidateInstalledPackageVersion(ctx, s, "moby-containerd", components.GetExpectedPackageVersions("containerd", "ubuntu", "r2204")[0]) ValidateInstalledPackageVersion(ctx, s, "moby-runc", components.GetExpectedPackageVersions("runc", "ubuntu", "r2204")[0]) ValidateSSHServiceEnabled(ctx, s) + ValidateFIPSProvider(ctx, s) }, }, }) @@ -840,6 +841,7 @@ func Test_Ubuntu2204Gen2FIPS(t *testing.T) { ValidateInstalledPackageVersion(ctx, s, "moby-containerd", components.GetExpectedPackageVersions("containerd", "ubuntu", "r2204")[0]) ValidateInstalledPackageVersion(ctx, s, "moby-runc", components.GetExpectedPackageVersions("runc", "ubuntu", "r2204")[0]) ValidateSSHServiceEnabled(ctx, s) + ValidateFIPSProvider(ctx, s) }, }, }) @@ -869,6 +871,7 @@ func Test_Ubuntu2204Gen2FIPSTL(t *testing.T) { ValidateInstalledPackageVersion(ctx, s, "moby-containerd", components.GetExpectedPackageVersions("containerd", "ubuntu", "r2204")[0]) ValidateInstalledPackageVersion(ctx, s, "moby-runc", components.GetExpectedPackageVersions("runc", "ubuntu", "r2204")[0]) ValidateSSHServiceEnabled(ctx, s) + ValidateFIPSProvider(ctx, s) }, }, }) diff --git a/e2e/validators.go b/e2e/validators.go index 65b8123e0c3..eb36a3d8ec6 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -655,7 +655,9 @@ func ValidateFileExcludesExactContent(ctx context.Context, s *Scenario, fileName // ValidateFIPSProvider verifies that FIPS is properly configured on the node: // 1. Kernel FIPS mode is enabled (/proc/sys/crypto/fips_enabled == 1). -// 2. OpenSSL has an active FIPS or SymCrypt provider loaded. +// 2. OpenSSL (3.x) has an active FIPS or SymCrypt provider loaded. The check is +// skipped on hosts shipping OpenSSL 1.1.x (e.g. Ubuntu 20.04 FIPS), which use +// the legacy FIPS module rather than the providers interface. // 3. /opt/cni/bin/portmap runs without panicking (regression guard for ICM 51000001009688 // where the OpenSSL FIPS provider was not loaded on AzureLinux V3 FIPS nodes). func ValidateFIPSProvider(ctx context.Context, s *Scenario) { @@ -665,20 +667,59 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { fipsEnabled := execScriptOnVMForScenarioValidateExitCode(ctx, s, "cat /proc/sys/crypto/fips_enabled", 0, "could not read /proc/sys/crypto/fips_enabled") require.Equal(s.T, "1", strings.TrimSpace(fipsEnabled.stdout), "expected /proc/sys/crypto/fips_enabled to be 1, got %q", fipsEnabled.stdout) - // 2. OpenSSL provider must include an active fips or symcrypt provider. - providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") - require.Regexp(s.T, `(?s)(fips|symcrypt).*?status:\s*active`, providers.stdout, - "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) - - // 3. portmap must not panic. We exec it with no stdin/args; it will exit non-zero, - // but the output must not contain a Go panic stack trace. - portmap := execScriptOnVMForScenarioValidateExitCode(ctx, s, "/opt/cni/bin/portmap < /dev/null 2>&1 || true", 0, "could not run portmap") + // 2. OpenSSL provider must include an active fips or symcrypt provider on OpenSSL 3.x. + opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version", 0, "could not run openssl version") + versionFields := strings.Fields(opensslVersion.stdout) + if len(versionFields) >= 2 && strings.HasPrefix(versionFields[1], "3.") { + providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") + require.True(s.T, opensslProviderActive(providers.stdout, "fips", "symcrypt"), + "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) + } else { + s.T.Logf("openssl providers check skipped: detected version %q (legacy FIPS module)", strings.TrimSpace(opensslVersion.stdout)) + } + + // 3. portmap must exist, be executable, and not panic when invoked. We assert the binary + // is present first so that a missing/non-executable portmap cannot silently pass the test, + // then exec it allowing the expected non-zero "usage" exit while checking for a panic trace. + portmapBin := "/opt/cni/bin/portmap" + portmapExists := execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, + fmt.Sprintf("expected %s to exist and be executable", portmapBin)) + _ = portmapExists + portmapCmd := fmt.Sprintf("%s < /dev/null 2>&1; ec=$?; echo PORTMAP_EXIT=$ec; exit 0", portmapBin) + portmap := execScriptOnVMForScenarioValidateExitCode(ctx, s, portmapCmd, 0, "could not run portmap") combined := portmap.stdout + portmap.stderr require.NotContains(s.T, combined, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\n%s", combined) s.T.Logf("FIPS provider validation passed") } +// opensslProviderActive parses the output of `openssl list -providers` and returns true if +// any of the named providers is reported with `status: active`. The status line is scoped +// to its enclosing provider block so an active default provider cannot mask an inactive +// fips/symcrypt provider. +func opensslProviderActive(output string, providerNames ...string) bool { + providerHeader := regexp.MustCompile(`^ (\S+)\s*$`) + statusLine := regexp.MustCompile(`^\s+status:\s*(\S+)`) + wanted := make(map[string]bool, len(providerNames)) + for _, n := range providerNames { + wanted[n] = true + } + var current string + for _, line := range strings.Split(output, "\n") { + if m := providerHeader.FindStringSubmatch(line); m != nil { + current = m[1] + continue + } + if current == "" || !wanted[current] { + continue + } + if m := statusLine.FindStringSubmatch(line); m != nil && m[1] == "active" { + return true + } + } + return false +} + func ServiceCanRestartValidator(ctx context.Context, s *Scenario, serviceName string, restartTimeoutInSeconds int) { s.T.Helper() steps := []string{ diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index 19d52b246b6..e1a1fbdd182 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -642,17 +642,29 @@ testFips() { providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" - # shellcheck disable=SC3010 - if [[ "${providers_output}" =~ (fips|symcrypt) ]]; then - echo "openssl FIPS/SymCrypt provider is registered." - else - err $test "openssl does not have a fips or symcrypt provider registered." - fi - # shellcheck disable=SC3010 - if [[ "${providers_output}" =~ status:[[:space:]]+active ]]; then - echo "openssl FIPS/SymCrypt provider has status: active." + # Walk each provider block and record the status of fips/symcrypt providers. + # This ensures `status: active` is tied to the fips/symcrypt block specifically + # and cannot be satisfied by another active provider (e.g. default). + fips_active="" + current_provider="" + while IFS= read -r line; do + # Provider header: exactly two leading spaces followed by the provider name. + # shellcheck disable=SC3010 + if [[ "${line}" =~ ^[[:space:]]{2}([^[:space:]]+)[[:space:]]*$ ]]; then + current_provider="${BASH_REMATCH[1]}" + continue + fi + # shellcheck disable=SC3010 + if [[ "${current_provider}" == "fips" || "${current_provider}" == "symcrypt" ]] \ + && [[ "${line}" =~ status:[[:space:]]+active ]]; then + fips_active="${current_provider}" + break + fi + done <<< "${providers_output}" + if [ -n "${fips_active}" ]; then + echo "openssl provider '${fips_active}' is registered and active." else - err $test "openssl FIPS/SymCrypt provider is not active." + err $test "openssl does not have an active fips or symcrypt provider." fi else echo "openssl providers check skipped: ${os_version} ships OpenSSL 1.1.x (legacy FIPS module)." From a54acf9ce531441c124858a94e010fef277443ac Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 15:35:55 -0700 Subject: [PATCH 06/19] fix: match symcryptprovider and harden FIPS provider parsing The OpenSSL provider on AzureLinux V3 / ACL FIPS images is exposed as 'symcryptprovider', not 'symcrypt'. ValidateFIPSProvider was doing an exact-string match and so failed all Azl3/ACL FIPS scenarios even when SymCrypt was loaded and active. Switch opensslProviderActive to prefix-match the provider name so a single call covers 'fips', 'symcrypt', and 'symcryptprovider'. While here, address review feedback: - Loosen the provider-header regex to tolerate any leading whitespace (spaces or tabs) instead of exactly two spaces. - Drop the throwaway '_ = portmapExists' assignment. - Stop merging stderr into stdout and forcing 'exit 0' when running portmap; capture streams separately and preserve the real exit code so a Go runtime panic remains observable. - Mirror the same prefix match and regex tolerance in vhdbuilder/packer/test/linux-vhd-content-test.sh, and derive the OpenSSL 3.x gate from 'openssl version' instead of a hand-maintained OS allowlist. Add a Go unit test for opensslProviderActive covering the symcryptprovider, inactive-provider, missing-provider and tab-indented-output cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 40 ++++++---- e2e/validators_test.go | 80 +++++++++++++++++++ .../packer/test/linux-vhd-content-test.sh | 21 +++-- 3 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 e2e/validators_test.go diff --git a/e2e/validators.go b/e2e/validators.go index eb36a3d8ec6..de5c93b8ade 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -672,6 +672,9 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { versionFields := strings.Fields(opensslVersion.stdout) if len(versionFields) >= 2 && strings.HasPrefix(versionFields[1], "3.") { providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") + // Accept any provider whose name starts with "fips" or "symcrypt" so we match + // "fips", "symcrypt", and "symcryptprovider" (the latter is what AzureLinux V3 / + // ACL FIPS images expose). See ICM 51000001009688. require.True(s.T, opensslProviderActive(providers.stdout, "fips", "symcrypt"), "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) } else { @@ -681,28 +684,35 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { // 3. portmap must exist, be executable, and not panic when invoked. We assert the binary // is present first so that a missing/non-executable portmap cannot silently pass the test, // then exec it allowing the expected non-zero "usage" exit while checking for a panic trace. + // A Go runtime panic writes to stderr and exits non-zero; we capture both streams separately + // (without merging) and preserve the real exit code so a panic remains observable here. portmapBin := "/opt/cni/bin/portmap" - portmapExists := execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, + execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, fmt.Sprintf("expected %s to exist and be executable", portmapBin)) - _ = portmapExists - portmapCmd := fmt.Sprintf("%s < /dev/null 2>&1; ec=$?; echo PORTMAP_EXIT=$ec; exit 0", portmapBin) - portmap := execScriptOnVMForScenarioValidateExitCode(ctx, s, portmapCmd, 0, "could not run portmap") - combined := portmap.stdout + portmap.stderr - require.NotContains(s.T, combined, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\n%s", combined) + portmap := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("%s < /dev/null", portmapBin)) + require.NotContains(s.T, portmap.stderr, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", portmap.stdout, portmap.stderr) + require.NotContains(s.T, portmap.stdout, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", portmap.stdout, portmap.stderr) s.T.Logf("FIPS provider validation passed") } // opensslProviderActive parses the output of `openssl list -providers` and returns true if -// any of the named providers is reported with `status: active`. The status line is scoped -// to its enclosing provider block so an active default provider cannot mask an inactive -// fips/symcrypt provider. -func opensslProviderActive(output string, providerNames ...string) bool { - providerHeader := regexp.MustCompile(`^ (\S+)\s*$`) +// any provider whose name has one of the given prefixes is reported with `status: active`. +// Prefix matching lets a single call cover related provider names (e.g. "symcrypt" matches +// both "symcrypt" and "symcryptprovider"). The status line is scoped to its enclosing +// provider block so an active default provider cannot mask an inactive fips/symcrypt provider. +func opensslProviderActive(output string, providerPrefixes ...string) bool { + // Provider headers are indented (typically two spaces, but tolerate tabs / extra padding) + // and have no key/value separator. Status lines are more deeply indented and contain ':'. + providerHeader := regexp.MustCompile(`^[ \t]+(\S+)\s*$`) statusLine := regexp.MustCompile(`^\s+status:\s*(\S+)`) - wanted := make(map[string]bool, len(providerNames)) - for _, n := range providerNames { - wanted[n] = true + matches := func(name string) bool { + for _, p := range providerPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false } var current string for _, line := range strings.Split(output, "\n") { @@ -710,7 +720,7 @@ func opensslProviderActive(output string, providerNames ...string) bool { current = m[1] continue } - if current == "" || !wanted[current] { + if current == "" || !matches(current) { continue } if m := statusLine.FindStringSubmatch(line); m != nil && m[1] == "active" { diff --git a/e2e/validators_test.go b/e2e/validators_test.go new file mode 100644 index 00000000000..ce400d0cc05 --- /dev/null +++ b/e2e/validators_test.go @@ -0,0 +1,80 @@ +package e2e + +import "testing" + +// TestOpensslProviderActive exercises the parser used by ValidateFIPSProvider so we don't +// regress on real-world `openssl list -providers` output shapes — in particular, the +// AzureLinux V3 / ACL FIPS images expose the provider as "symcryptprovider" rather than +// "symcrypt" (ICM 51000001009688) and indentation has varied between distros. +func TestOpensslProviderActive(t *testing.T) { + cases := []struct { + name string + output string + prefixes []string + want bool + }{ + { + name: "symcryptprovider active on AzureLinux V3 matches symcrypt prefix", + output: `Providers: + default + name: OpenSSL Default Provider + version: 3.3.0 + status: active + symcryptprovider + name: SymCrypt Provider + version: 103.4.2 + status: active +`, + prefixes: []string{"fips", "symcrypt"}, + want: true, + }, + { + name: "fips provider active", + output: `Providers: + default + status: active + fips + name: OpenSSL FIPS Provider + status: active +`, + prefixes: []string{"fips", "symcrypt"}, + want: true, + }, + { + name: "symcrypt provider inactive, default active does not satisfy", + output: `Providers: + default + name: OpenSSL Default Provider + status: active + symcrypt + name: SymCrypt Provider + status: inactive +`, + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, + { + name: "no fips or symcrypt provider listed", + output: `Providers: + default + status: active +`, + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, + { + name: "tolerates tab-indented provider header", + output: "Providers:\n\tsymcryptprovider\n\t\tstatus: active\n", + prefixes: []string{"fips", "symcrypt"}, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := opensslProviderActive(tc.output, tc.prefixes...) + if got != tc.want { + t.Fatalf("opensslProviderActive() = %v, want %v\noutput:\n%s", got, tc.want, tc.output) + } + }) + } +} diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index e1a1fbdd182..e4fa134cdc8 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -634,28 +634,33 @@ testFips() { # the kernel FIPS flag was set but the OpenSSL provider was missing, # causing /opt/cni/bin/portmap to panic at runtime. # - # Only run on OS variants that ship OpenSSL 3.x (the providers concept - # doesn't exist in OpenSSL 1.1.x). Ubuntu 20.04 and AzureLinux V2 ship - # OpenSSL 1.1.x and use the legacy FIPS module model. + # Only run on OpenSSL 3.x (the providers concept doesn't exist in 1.1.x). + # Derive this from `openssl version` directly so the check stays in sync with the + # Go validator (e2e/validators.go) and doesn't need an OS allowlist. + openssl_version_raw=$(openssl version 2>/dev/null || true) + openssl_major=$(echo "${openssl_version_raw}" | awk '{print $2}' | cut -d. -f1) # shellcheck disable=SC3010 - if [[ ${os_version} == "22.04" || ${os_version} == "24.04" || ${os_version} == "V3" || ${os_version} == "OSGuardV3" || ${os_version} == "acl" ]]; then + if [[ "${openssl_major}" == "3" ]]; then providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" # Walk each provider block and record the status of fips/symcrypt providers. # This ensures `status: active` is tied to the fips/symcrypt block specifically # and cannot be satisfied by another active provider (e.g. default). + # Match by prefix so "symcrypt" covers both "symcrypt" and "symcryptprovider" + # (the latter is what AzureLinux V3 / ACL FIPS images expose). fips_active="" current_provider="" while IFS= read -r line; do - # Provider header: exactly two leading spaces followed by the provider name. + # Provider header: leading whitespace (spaces or tabs) followed by the provider name + # with no ':' (status/version lines are key:value). Tolerate variable indentation. # shellcheck disable=SC3010 - if [[ "${line}" =~ ^[[:space:]]{2}([^[:space:]]+)[[:space:]]*$ ]]; then + if [[ "${line}" =~ ^[[:space:]]+([^[:space:]:]+)[[:space:]]*$ ]]; then current_provider="${BASH_REMATCH[1]}" continue fi # shellcheck disable=SC3010 - if [[ "${current_provider}" == "fips" || "${current_provider}" == "symcrypt" ]] \ + if [[ "${current_provider}" == fips* || "${current_provider}" == symcrypt* ]] \ && [[ "${line}" =~ status:[[:space:]]+active ]]; then fips_active="${current_provider}" break @@ -667,7 +672,7 @@ testFips() { err $test "openssl does not have an active fips or symcrypt provider." fi else - echo "openssl providers check skipped: ${os_version} ships OpenSSL 1.1.x (legacy FIPS module)." + echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." fi fi From 5b52f6dce6a5958c4e81c28a6e750b8800f98a96 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 15:39:37 -0700 Subject: [PATCH 07/19] test: address remaining FIPS-validation review feedback - Add ValidateFIPSProvider to Test_Ubuntu2004FIPS so all FIPS scenarios exercise the same check (djsly: 'should verify on all nodes'). The validator already skips the OpenSSL providers block on OpenSSL 1.1.x, so Ubuntu 20.04 still uses the legacy FIPS module path. - Add an /etc/os-release content assertion to Test_AzureLinuxV3Gen2FIPS so the scenario meaningfully validates the V3 FIPS VHD bootstrap, not just FIPS provider state. - Add an explicit os_version allowlist guard at the top of testFips in linux-vhd-content-test.sh. Unknown distros invoked with enable_fips=true now fail loudly instead of silently falling through the OpenSSL 1.1.x legacy path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/scenario_test.go | 2 ++ vhdbuilder/packer/test/linux-vhd-content-test.sh | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index cabb0aa7af1..0b705cc6946 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -284,6 +284,7 @@ func Test_AzureLinuxV3Gen2FIPS(t *testing.T) { } }, Validator: func(ctx context.Context, s *Scenario) { + ValidateFileHasContent(ctx, s, "/etc/os-release", "ID=azurelinux") ValidateFIPSProvider(ctx, s) }, }, @@ -813,6 +814,7 @@ func Test_Ubuntu2004FIPS(t *testing.T) { ValidateInstalledPackageVersion(ctx, s, "moby-containerd", components.GetExpectedPackageVersions("containerd", "ubuntu", "r2004")[0]) ValidateInstalledPackageVersion(ctx, s, "moby-runc", components.GetExpectedPackageVersions("runc", "ubuntu", "r2004")[0]) ValidateSSHServiceEnabled(ctx, s) + ValidateFIPSProvider(ctx, s) }, }, }) diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index e4fa134cdc8..bcf60a7658e 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -594,6 +594,20 @@ testFips() { os_version=$1 enable_fips=$2 + # Known FIPS-capable os_version values. Adding a new distro that ships with + # FIPS enabled MUST be added here, otherwise the test silently no-ops and the + # OpenSSL provider regression that motivated this check (ICM 51000001009688) + # could slip through on the new image. + case "${os_version}" in + 20.04|22.04|24.04|V3|OSGuardV3|acl) ;; + *) + # shellcheck disable=SC3010 + if [[ ${enable_fips,,} == "true" ]]; then + err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." + fi + ;; + esac + # shellcheck disable=SC3010 if [[ ${enable_fips,,} == "true" ]]; then if [ -f /proc/sys/crypto/fips_enabled ]; then From de27f3b2a9e930086464ea672080885cfe28f8ff Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 15:40:33 -0700 Subject: [PATCH 08/19] test: assert containerd2 package version in AzureLinuxV3 FIPS scenario Replace the tautological /etc/os-release ID=azurelinux check with the same containerd2 package-version assertion used by other AzureLinuxV3 scenarios. The VHD selector already pins the image to azurelinux, so checking /etc/os-release adds no signal; verifying the FIPS-baked containerd package version actually catches VHD build regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/scenario_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index 0b705cc6946..ae0267f26e9 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -284,7 +284,7 @@ func Test_AzureLinuxV3Gen2FIPS(t *testing.T) { } }, Validator: func(ctx context.Context, s *Scenario) { - ValidateFileHasContent(ctx, s, "/etc/os-release", "ID=azurelinux") + ValidateInstalledPackageVersion(ctx, s, "containerd2", components.GetExpectedPackageVersions("containerd", "azurelinux", "v3.0")[0]) ValidateFIPSProvider(ctx, s) }, }, From 3e9e74b19427c5118af4e198f3d15e1416298876 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 15:41:40 -0700 Subject: [PATCH 09/19] test: keep Test_AzureLinuxV3Gen2FIPS focused on FIPS validation Revert the containerd2 package-version assertion. Non-FIPS Test_AzureLinuxV3_* scenarios already cover that, and package-version verification is the job of the build-time linux-vhd-content-test.sh, not e2e. This scenario exists specifically to validate FIPS, so keep it tightly scoped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/scenario_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/scenario_test.go b/e2e/scenario_test.go index ae0267f26e9..0dba705a302 100644 --- a/e2e/scenario_test.go +++ b/e2e/scenario_test.go @@ -284,7 +284,6 @@ func Test_AzureLinuxV3Gen2FIPS(t *testing.T) { } }, Validator: func(ctx context.Context, s *Scenario) { - ValidateInstalledPackageVersion(ctx, s, "containerd2", components.GetExpectedPackageVersions("containerd", "azurelinux", "v3.0")[0]) ValidateFIPSProvider(ctx, s) }, }, From 39c1950b8c3083c704d7faf1677c257f594f2f2b Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 15:43:25 -0700 Subject: [PATCH 10/19] refactor: drop redundant kernel-FIPS check from ValidateACLFIPSEnabled In Test_ACLGen2FIPSTL we call ValidateACLFIPSEnabled immediately before ValidateFIPSProvider, and both validators independently cat /proc/sys/crypto/fips_enabled. Narrow ValidateACLFIPSEnabled to just the ACL-specific /etc/system-fips marker so each helper has a non-overlapping responsibility and we save one SSH round-trip per scenario run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index de5c93b8ade..5bf10b0db72 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -486,16 +486,13 @@ func ValidateFileExists(ctx context.Context, s *Scenario, fileName string) { } } +// ValidateACLFIPSEnabled asserts ACL-specific FIPS markers are present on the node. +// The kernel-FIPS flag check (/proc/sys/crypto/fips_enabled == 1) is intentionally +// not duplicated here: callers in FIPS scenarios pair this with ValidateFIPSProvider, +// which already asserts that. func ValidateACLFIPSEnabled(ctx context.Context, s *Scenario) { s.T.Helper() ValidateFileExists(ctx, s, "/etc/system-fips") - execScriptOnVMForScenarioValidateExitCode( - ctx, - s, - `test "$(cat /proc/sys/crypto/fips_enabled)" = "1"`, - 0, - "expected /proc/sys/crypto/fips_enabled to be 1", - ) } func ValidateFileDoesNotExist(ctx context.Context, s *Scenario, fileName string) { From fab36af9795c88bbbd5aaeeb7a797a3195217de6 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 16:02:31 -0700 Subject: [PATCH 11/19] test: address remaining FIPS validator review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the FIPS provider validation work: e2e/validators.go - Revert ValidateACLFIPSEnabled to also assert /proc/sys/crypto/fips_enabled == 1. Removing it created a fragile cross-helper contract; one duplicate SSH cat is preferable. - Treat malformed 'openssl version' output as a hard failure in ValidateFIPSProvider instead of silently logging 'skipped' — FIPS VHDs are expected to ship a functioning OpenSSL. - Broaden portmap failure-marker detection to also catch 'fatal error:' and 'runtime error:' alongside 'panic:'. - Promote provider header / status line regexes to package-level vars compiled once at init. e2e/validators_test.go - Add a case asserting that multiple active providers (default+legacy) with no fips/symcrypt header at all is still rejected. vhdbuilder/packer/test/linux-vhd-content-test.sh - Restore 'V2' to the FIPS os_version allowlist (was previously accepted, dropping it broke any caller passing V2). - Make the unrecognized-os_version branch 'return' after err so the FIPS body doesn't run with bogus inputs and emit follow-on errors. - Fail loudly (err) when openssl is missing or its version is unparsable, instead of silently logging 'skipped: detected version ''' and passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 47 ++++++++++++++----- e2e/validators_test.go | 13 +++++ .../packer/test/linux-vhd-content-test.sh | 43 ++++++++++------- 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index 5bf10b0db72..cebdd260a21 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -487,12 +487,20 @@ func ValidateFileExists(ctx context.Context, s *Scenario, fileName string) { } // ValidateACLFIPSEnabled asserts ACL-specific FIPS markers are present on the node. -// The kernel-FIPS flag check (/proc/sys/crypto/fips_enabled == 1) is intentionally -// not duplicated here: callers in FIPS scenarios pair this with ValidateFIPSProvider, -// which already asserts that. +// The kernel-FIPS check is intentionally kept here so callers using only this helper +// still get coverage. Callers that also invoke ValidateFIPSProvider will end up +// asserting `/proc/sys/crypto/fips_enabled == 1` twice; the cost is one extra SSH +// round-trip and is preferred over a fragile cross-helper contract. func ValidateACLFIPSEnabled(ctx context.Context, s *Scenario) { s.T.Helper() ValidateFileExists(ctx, s, "/etc/system-fips") + execScriptOnVMForScenarioValidateExitCode( + ctx, + s, + `test "$(cat /proc/sys/crypto/fips_enabled)" = "1"`, + 0, + "expected /proc/sys/crypto/fips_enabled to be 1", + ) } func ValidateFileDoesNotExist(ctx context.Context, s *Scenario, fileName string) { @@ -665,9 +673,13 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { require.Equal(s.T, "1", strings.TrimSpace(fipsEnabled.stdout), "expected /proc/sys/crypto/fips_enabled to be 1, got %q", fipsEnabled.stdout) // 2. OpenSSL provider must include an active fips or symcrypt provider on OpenSSL 3.x. + // Treat malformed `openssl version` output as a hard failure rather than silently + // skipping the check — FIPS VHDs are expected to ship a functioning OpenSSL. opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version", 0, "could not run openssl version") versionFields := strings.Fields(opensslVersion.stdout) - if len(versionFields) >= 2 && strings.HasPrefix(versionFields[1], "3.") { + require.GreaterOrEqual(s.T, len(versionFields), 2, + "could not parse openssl version output: %q", opensslVersion.stdout) + if strings.HasPrefix(versionFields[1], "3.") { providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") // Accept any provider whose name starts with "fips" or "symcrypt" so we match // "fips", "symcrypt", and "symcryptprovider" (the latter is what AzureLinux V3 / @@ -683,26 +695,37 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { // then exec it allowing the expected non-zero "usage" exit while checking for a panic trace. // A Go runtime panic writes to stderr and exits non-zero; we capture both streams separately // (without merging) and preserve the real exit code so a panic remains observable here. + // Match a broader set of Go runtime failure markers ("panic:", "fatal error:", "runtime error:") + // so closely related FIPS-misconfiguration failure modes are caught alongside vanilla panics. portmapBin := "/opt/cni/bin/portmap" execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, fmt.Sprintf("expected %s to exist and be executable", portmapBin)) portmap := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("%s < /dev/null", portmapBin)) - require.NotContains(s.T, portmap.stderr, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", portmap.stdout, portmap.stderr) - require.NotContains(s.T, portmap.stdout, "panic:", "portmap panicked, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", portmap.stdout, portmap.stderr) + for _, marker := range []string{"panic:", "fatal error:", "runtime error:"} { + require.NotContains(s.T, portmap.stderr, marker, + "portmap %s detected, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", marker, portmap.stdout, portmap.stderr) + require.NotContains(s.T, portmap.stdout, marker, + "portmap %s detected, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", marker, portmap.stdout, portmap.stderr) + } s.T.Logf("FIPS provider validation passed") } +// Package-level regex compiled once at init so a bad pattern fails fast at startup +// rather than on first call. +var ( + // Provider headers are indented (typically two spaces, but tolerate tabs / extra padding) + // and have no key/value separator. Status lines are more deeply indented and contain ':'. + opensslProviderHeaderRE = regexp.MustCompile(`^[ \t]+(\S+)\s*$`) + opensslStatusLineRE = regexp.MustCompile(`^\s+status:\s*(\S+)`) +) + // opensslProviderActive parses the output of `openssl list -providers` and returns true if // any provider whose name has one of the given prefixes is reported with `status: active`. // Prefix matching lets a single call cover related provider names (e.g. "symcrypt" matches // both "symcrypt" and "symcryptprovider"). The status line is scoped to its enclosing // provider block so an active default provider cannot mask an inactive fips/symcrypt provider. func opensslProviderActive(output string, providerPrefixes ...string) bool { - // Provider headers are indented (typically two spaces, but tolerate tabs / extra padding) - // and have no key/value separator. Status lines are more deeply indented and contain ':'. - providerHeader := regexp.MustCompile(`^[ \t]+(\S+)\s*$`) - statusLine := regexp.MustCompile(`^\s+status:\s*(\S+)`) matches := func(name string) bool { for _, p := range providerPrefixes { if strings.HasPrefix(name, p) { @@ -713,14 +736,14 @@ func opensslProviderActive(output string, providerPrefixes ...string) bool { } var current string for _, line := range strings.Split(output, "\n") { - if m := providerHeader.FindStringSubmatch(line); m != nil { + if m := opensslProviderHeaderRE.FindStringSubmatch(line); m != nil { current = m[1] continue } if current == "" || !matches(current) { continue } - if m := statusLine.FindStringSubmatch(line); m != nil && m[1] == "active" { + if m := opensslStatusLineRE.FindStringSubmatch(line); m != nil && m[1] == "active" { return true } } diff --git a/e2e/validators_test.go b/e2e/validators_test.go index ce400d0cc05..437665f4d57 100644 --- a/e2e/validators_test.go +++ b/e2e/validators_test.go @@ -49,6 +49,19 @@ func TestOpensslProviderActive(t *testing.T) { symcrypt name: SymCrypt Provider status: inactive +`, + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, + { + name: "multiple active providers but no fips/symcrypt header at all", + output: `Providers: + default + name: OpenSSL Default Provider + status: active + legacy + name: OpenSSL Legacy Provider + status: active `, prefixes: []string{"fips", "symcrypt"}, want: false, diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index bcf60a7658e..38e93977f03 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -595,18 +595,22 @@ testFips() { enable_fips=$2 # Known FIPS-capable os_version values. Adding a new distro that ships with - # FIPS enabled MUST be added here, otherwise the test silently no-ops and the - # OpenSSL provider regression that motivated this check (ICM 51000001009688) - # could slip through on the new image. - case "${os_version}" in - 20.04|22.04|24.04|V3|OSGuardV3|acl) ;; - *) - # shellcheck disable=SC3010 - if [[ ${enable_fips,,} == "true" ]]; then + # FIPS enabled MUST be added here, otherwise the test fails loudly instead of + # silently no-op'ing (which is what motivated this guard — ICM 51000001009688). + # NOTE: `err` reports to stderr (the runner greps stderr for failures) but does + # not exit, so we explicitly `return` to avoid running the FIPS body afterwards + # with an unsupported os_version. + # shellcheck disable=SC3010 + if [[ ${enable_fips,,} == "true" ]]; then + case "${os_version}" in + 20.04|22.04|24.04|V2|V3|OSGuardV3|acl) ;; + *) err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." - fi - ;; - esac + echo "$test:Finish" + return + ;; + esac + fi # shellcheck disable=SC3010 if [[ ${enable_fips,,} == "true" ]]; then @@ -651,10 +655,14 @@ testFips() { # Only run on OpenSSL 3.x (the providers concept doesn't exist in 1.1.x). # Derive this from `openssl version` directly so the check stays in sync with the # Go validator (e2e/validators.go) and doesn't need an OS allowlist. - openssl_version_raw=$(openssl version 2>/dev/null || true) - openssl_major=$(echo "${openssl_version_raw}" | awk '{print $2}' | cut -d. -f1) - # shellcheck disable=SC3010 - if [[ "${openssl_major}" == "3" ]]; then + if ! command -v openssl >/dev/null 2>&1; then + err $test "openssl binary not found on a FIPS-enabled VHD." + else + openssl_version_raw=$(openssl version 2>&1) + openssl_major=$(echo "${openssl_version_raw}" | awk '{print $2}' | cut -d. -f1) + if [ -z "${openssl_major}" ]; then + err $test "could not parse openssl version (raw output: '${openssl_version_raw}')." + elif [ "${openssl_major}" = "3" ]; then providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" @@ -685,8 +693,9 @@ testFips() { else err $test "openssl does not have an active fips or symcrypt provider." fi - else - echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." + else + echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." + fi fi fi From 4564ee6b4c7baba828f87ec52b864e3c830088d3 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 16:19:25 -0700 Subject: [PATCH 12/19] test: fail loudly on unexpected openssl version in FIPS validation Previously the OpenSSL providers check skipped silently for any version that didn't start with '3.'. That means a future 'OpenSSL 4.x' or a malformed/unrecognized version string would silently pass on a FIPS VHD. Switch to a strict allowlist: 3.x runs the providers check, 1.1.x skips (legacy FIPS module), anything else is a hard failure that forces explicit handling when a new openssl branch is supported. Applied symmetrically to e2e/validators.go (ValidateFIPSProvider) and vhdbuilder/packer/test/linux-vhd-content-test.sh (testFips). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 14 +++++++--- .../packer/test/linux-vhd-content-test.sh | 28 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index cebdd260a21..e54926d5d7a 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -673,21 +673,27 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { require.Equal(s.T, "1", strings.TrimSpace(fipsEnabled.stdout), "expected /proc/sys/crypto/fips_enabled to be 1, got %q", fipsEnabled.stdout) // 2. OpenSSL provider must include an active fips or symcrypt provider on OpenSSL 3.x. - // Treat malformed `openssl version` output as a hard failure rather than silently - // skipping the check — FIPS VHDs are expected to ship a functioning OpenSSL. + // Treat malformed or unrecognized `openssl version` output as a hard failure rather + // than silently skipping the check. FIPS VHDs are expected to ship OpenSSL 3.x except + // Ubuntu 20.04 which ships 1.1.x (legacy FIPS module, no providers concept). Any other + // version is unexpected and must fail loudly so we don't lose coverage on a future image. opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version", 0, "could not run openssl version") versionFields := strings.Fields(opensslVersion.stdout) require.GreaterOrEqual(s.T, len(versionFields), 2, "could not parse openssl version output: %q", opensslVersion.stdout) - if strings.HasPrefix(versionFields[1], "3.") { + version := versionFields[1] + switch { + case strings.HasPrefix(version, "3."): providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") // Accept any provider whose name starts with "fips" or "symcrypt" so we match // "fips", "symcrypt", and "symcryptprovider" (the latter is what AzureLinux V3 / // ACL FIPS images expose). See ICM 51000001009688. require.True(s.T, opensslProviderActive(providers.stdout, "fips", "symcrypt"), "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) - } else { + case strings.HasPrefix(version, "1.1."): s.T.Logf("openssl providers check skipped: detected version %q (legacy FIPS module)", strings.TrimSpace(opensslVersion.stdout)) + default: + s.T.Fatalf("unexpected openssl version %q: FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported", strings.TrimSpace(opensslVersion.stdout)) } // 3. portmap must exist, be executable, and not panic when invoked. We assert the binary diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index 38e93977f03..b8500811924 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -652,17 +652,20 @@ testFips() { # the kernel FIPS flag was set but the OpenSSL provider was missing, # causing /opt/cni/bin/portmap to panic at runtime. # - # Only run on OpenSSL 3.x (the providers concept doesn't exist in 1.1.x). - # Derive this from `openssl version` directly so the check stays in sync with the - # Go validator (e2e/validators.go) and doesn't need an OS allowlist. + # Only run the providers check on OpenSSL 3.x (the providers concept doesn't exist + # in 1.1.x). Skip on 1.1.x (legacy FIPS module). Any other version is unexpected on + # a FIPS VHD and must be a hard failure so new images don't silently lose coverage. + # Keep this in sync with the Go validator in e2e/validators.go. if ! command -v openssl >/dev/null 2>&1; then err $test "openssl binary not found on a FIPS-enabled VHD." else openssl_version_raw=$(openssl version 2>&1) - openssl_major=$(echo "${openssl_version_raw}" | awk '{print $2}' | cut -d. -f1) - if [ -z "${openssl_major}" ]; then - err $test "could not parse openssl version (raw output: '${openssl_version_raw}')." - elif [ "${openssl_major}" = "3" ]; then + openssl_version=$(echo "${openssl_version_raw}" | awk '{print $2}') + case "${openssl_version}" in + "") + err $test "could not parse openssl version (raw output: '${openssl_version_raw}')." + ;; + 3.*) providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" @@ -693,9 +696,14 @@ testFips() { else err $test "openssl does not have an active fips or symcrypt provider." fi - else - echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." - fi + ;; + 1.1.*) + echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." + ;; + *) + err $test "unexpected openssl version '${openssl_version_raw}': FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported." + ;; + esac fi fi From bcc8d7665889d17b7b25a3d7da03a58a64ef646f Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 16:20:16 -0700 Subject: [PATCH 13/19] test: add interleaved-layout regression case for opensslProviderActive Cover the case where an inactive symcrypt provider block is followed by an active default provider block. This is the most likely real-world misparse and explicitly locks in the 'status scoped to enclosing provider block' guarantee documented on the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/e2e/validators_test.go b/e2e/validators_test.go index 437665f4d57..1552b57081e 100644 --- a/e2e/validators_test.go +++ b/e2e/validators_test.go @@ -62,6 +62,23 @@ func TestOpensslProviderActive(t *testing.T) { legacy name: OpenSSL Legacy Provider status: active +`, + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, + { + // Regression guard for the "status scoped to enclosing block" guarantee: + // an inactive symcrypt block followed by an active default block must NOT + // be treated as symcrypt being active just because an "active" line appears + // later in the output. This is the most likely real-world misparse. + name: "interleaved: inactive symcrypt then active default does not satisfy", + output: `Providers: + symcrypt + name: SymCrypt Provider + status: inactive + default + name: OpenSSL Default Provider + status: active `, prefixes: []string{"fips", "symcrypt"}, want: false, From 24ff4f8c89f5e85601368e7dead1e8ae0296613e Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 21:20:34 -0700 Subject: [PATCH 14/19] fix(testFips): align allowlist with /etc/os-release VERSION_ID values cse_helpers.sh (sourced by linux-vhd-content-test.sh) overwrites the global OS_VERSION from /etc/os-release VERSION_ID, so the call site `testFips $OS_VERSION ...` passes the sourced value (e.g. "3.0", "2.0"), not the pipeline-style value ("V3", "OSGuardV3", "acl"). The strict allowlist introduced in 4564ee6b4c therefore rejected AzureLinux V3 / OSGuard / ACL FIPS images at runtime (build 164255293, OSGuard job: "testFips invoked with enable_fips=true on unrecognized os_version '3.0'"). This also reveals that the pre-PR `os_version = acl` branch was dead code; switch it to OS_SKU (which is not clobbered) so the ACL FIPS marker / UKI addon checks actually run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vhdbuilder/packer/test/linux-vhd-content-test.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index b8500811924..f9320e79123 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -591,6 +591,12 @@ testChrony() { testFips() { local test="testFips" echo "$test:Start" + # NOTE: although the pipeline passes pipeline-style OS_VERSION values such as + # "V2"/"V3"/"OSGuardV3"/"acl" via $1, by the time this function runs the + # global OS_VERSION has been overwritten by sourcing cse_helpers.sh, which + # sets it from /etc/os-release VERSION_ID (e.g. "2.0", "3.0", "20.04"). The + # call site `testFips $OS_VERSION ...` therefore passes the sourced value, + # not the pipeline value, so the allowlist below uses /etc/os-release values. os_version=$1 enable_fips=$2 @@ -603,7 +609,7 @@ testFips() { # shellcheck disable=SC3010 if [[ ${enable_fips,,} == "true" ]]; then case "${os_version}" in - 20.04|22.04|24.04|V2|V3|OSGuardV3|acl) ;; + 20.04|22.04|24.04|2.0|3.0) ;; *) err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." echo "$test:Finish" @@ -634,7 +640,7 @@ testFips() { fi fi - if [ ${os_version} = "acl" ]; then + if [ "${OS_SKU}" = "AzureContainerLinux" ]; then if [ -f /etc/system-fips ]; then echo "/etc/system-fips marker file exists." else From 01d8d22777bd3f46cec2678cfc7ed24ac01c032a Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 21:29:37 -0700 Subject: [PATCH 15/19] =?UTF-8?q?test(fips):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20merge=20guards,=20narrow=20runtime=20markers,=20tig?= =?UTF-8?q?hten=20indent=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e/validators.go: capture combined stdout+stderr for `openssl version` so a future build that prints to stderr doesn't yield an empty parse target (review #9). - e2e/validators.go: replace the bare `runtime error:` substring with regex markers (`^panic:`, `^fatal error:`, `runtime.gopanic`, `goroutine N [running]`) so CNI plugins that mention "runtime error:" in their usage text don't trip a false failure (review #8). - e2e/validators.go: track each provider header's indent and require status lines to be strictly more indented, closing the same-indent mis-attribution hole. Also strip trailing \r so CRLF-terminated remote output still matches the header regex (review #7, #10). - e2e/validators_test.go: add two regression cases — a "Providers:" header with no entries below, and a same-indent `status: active` line that must not be attributed to the prior header (review #17). - vhdbuilder/packer/test/linux-vhd-content-test.sh: collapse the two adjacent `if [[ enable_fips == true ]]` blocks into one early-return guard, dedent the body, and dedent the 3.* case arm so it visually belongs to the case (reviews #5, #6). Mirror the Go parser improvements: `[ \t]` rather than `[[:space:]]` in the header/status regexes, explicit \r stripping, and strict "status more indented than header" check (review #7, #10). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 49 ++++-- e2e/validators_test.go | 21 +++ .../packer/test/linux-vhd-content-test.sh | 161 ++++++++++-------- 3 files changed, 143 insertions(+), 88 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index e54926d5d7a..2b9c3e1372d 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -677,7 +677,9 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { // than silently skipping the check. FIPS VHDs are expected to ship OpenSSL 3.x except // Ubuntu 20.04 which ships 1.1.x (legacy FIPS module, no providers concept). Any other // version is unexpected and must fail loudly so we don't lose coverage on a future image. - opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version", 0, "could not run openssl version") + // Merge stderr into stdout (`2>&1`) so we don't get an empty `stdout` and an unhelpful + // parse error if a future build of openssl prints its version banner to stderr. + opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version 2>&1", 0, "could not run openssl version") versionFields := strings.Fields(opensslVersion.stdout) require.GreaterOrEqual(s.T, len(versionFields), 2, "could not parse openssl version output: %q", opensslVersion.stdout) @@ -701,17 +703,25 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { // then exec it allowing the expected non-zero "usage" exit while checking for a panic trace. // A Go runtime panic writes to stderr and exits non-zero; we capture both streams separately // (without merging) and preserve the real exit code so a panic remains observable here. - // Match a broader set of Go runtime failure markers ("panic:", "fatal error:", "runtime error:") - // so closely related FIPS-misconfiguration failure modes are caught alongside vanilla panics. + // Match specific Go runtime failure markers — `panic:`, `fatal error:`, and the more + // distinctive `goroutine N [running]:` / `runtime.gopanic` traces — rather than the bare + // substring `runtime error:` which can appear in unrelated CNI plugin usage/help text and + // cause false failures. portmapBin := "/opt/cni/bin/portmap" execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, fmt.Sprintf("expected %s to exist and be executable", portmapBin)) portmap := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("%s < /dev/null", portmapBin)) - for _, marker := range []string{"panic:", "fatal error:", "runtime error:"} { - require.NotContains(s.T, portmap.stderr, marker, - "portmap %s detected, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", marker, portmap.stdout, portmap.stderr) - require.NotContains(s.T, portmap.stdout, marker, - "portmap %s detected, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", marker, portmap.stdout, portmap.stderr) + panicMarkers := []*regexp.Regexp{ + regexp.MustCompile(`(?m)^panic:`), + regexp.MustCompile(`(?m)^fatal error:`), + regexp.MustCompile(`runtime\.gopanic`), + regexp.MustCompile(`goroutine \d+ \[running\]`), + } + for _, re := range panicMarkers { + require.False(s.T, re.MatchString(portmap.stderr), + "portmap runtime failure matched %q, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", re, portmap.stdout, portmap.stderr) + require.False(s.T, re.MatchString(portmap.stdout), + "portmap runtime failure matched %q, indicating FIPS provider misconfiguration:\nstdout:\n%s\nstderr:\n%s", re, portmap.stdout, portmap.stderr) } s.T.Logf("FIPS provider validation passed") @@ -721,9 +731,12 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { // rather than on first call. var ( // Provider headers are indented (typically two spaces, but tolerate tabs / extra padding) - // and have no key/value separator. Status lines are more deeply indented and contain ':'. - opensslProviderHeaderRE = regexp.MustCompile(`^[ \t]+(\S+)\s*$`) - opensslStatusLineRE = regexp.MustCompile(`^\s+status:\s*(\S+)`) + // and have no key/value separator. Capture group 1 is the leading indent, group 2 is the + // provider name; we keep the indent so status lines below can be required to be strictly + // more indented than their enclosing header (matching the parser's "scoped to enclosing + // block" guarantee — same-indent `status:` would otherwise be mis-attributed). + opensslProviderHeaderRE = regexp.MustCompile(`^([ \t]+)(\S+)\s*$`) + opensslStatusLineRE = regexp.MustCompile(`^[ \t]+status:\s*(\S+)`) ) // opensslProviderActive parses the output of `openssl list -providers` and returns true if @@ -731,6 +744,7 @@ var ( // Prefix matching lets a single call cover related provider names (e.g. "symcrypt" matches // both "symcrypt" and "symcryptprovider"). The status line is scoped to its enclosing // provider block so an active default provider cannot mask an inactive fips/symcrypt provider. +// Status lines must be strictly more indented than the header that opened the block. func opensslProviderActive(output string, providerPrefixes ...string) bool { matches := func(name string) bool { for _, p := range providerPrefixes { @@ -741,16 +755,25 @@ func opensslProviderActive(output string, providerPrefixes ...string) bool { return false } var current string + var headerIndent int for _, line := range strings.Split(output, "\n") { + // Strip a trailing CR so CRLF-terminated remote output still matches the + // `\s*$` anchor in the header regex. + line = strings.TrimRight(line, "\r") if m := opensslProviderHeaderRE.FindStringSubmatch(line); m != nil { - current = m[1] + headerIndent = len(m[1]) + current = m[2] continue } if current == "" || !matches(current) { continue } if m := opensslStatusLineRE.FindStringSubmatch(line); m != nil && m[1] == "active" { - return true + // Require the status line to be strictly more indented than the header. + leading := len(line) - len(strings.TrimLeft(line, " \t")) + if leading > headerIndent { + return true + } } } return false diff --git a/e2e/validators_test.go b/e2e/validators_test.go index 1552b57081e..64c814178a6 100644 --- a/e2e/validators_test.go +++ b/e2e/validators_test.go @@ -98,6 +98,27 @@ func TestOpensslProviderActive(t *testing.T) { prefixes: []string{"fips", "symcrypt"}, want: true, }, + { + // Lock in: the literal "Providers:" header line should match neither the + // header regex (no leading whitespace) nor the status regex; an output with + // only that line and no providers below must return false. + name: "providers header alone with no entries", + output: "Providers:\n", + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, + { + // Defensive: a `status: active` line at the same indent as the provider + // header must NOT satisfy the check — status lines are required to be + // strictly more indented than their enclosing block. + name: "status at same indent as header is not attributed", + output: `Providers: + symcrypt + status: active +`, + prefixes: []string{"fips", "symcrypt"}, + want: false, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index f9320e79123..a655ca57635 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -600,101 +600,114 @@ testFips() { os_version=$1 enable_fips=$2 + # shellcheck disable=SC3010 + if [[ ${enable_fips,,} != "true" ]]; then + echo "$test:Finish" + return + fi + # Known FIPS-capable os_version values. Adding a new distro that ships with # FIPS enabled MUST be added here, otherwise the test fails loudly instead of # silently no-op'ing (which is what motivated this guard — ICM 51000001009688). # NOTE: `err` reports to stderr (the runner greps stderr for failures) but does # not exit, so we explicitly `return` to avoid running the FIPS body afterwards # with an unsupported os_version. - # shellcheck disable=SC3010 - if [[ ${enable_fips,,} == "true" ]]; then - case "${os_version}" in - 20.04|22.04|24.04|2.0|3.0) ;; - *) - err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." - echo "$test:Finish" - return - ;; - esac - fi + case "${os_version}" in + 20.04|22.04|24.04|2.0|3.0) ;; + *) + err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." + echo "$test:Finish" + return + ;; + esac - # shellcheck disable=SC3010 - if [[ ${enable_fips,,} == "true" ]]; then - if [ -f /proc/sys/crypto/fips_enabled ]; then - fips_enabled=$(cat /proc/sys/crypto/fips_enabled) - if [ "${fips_enabled}" = "1" ]; then - echo "FIPS is enabled." - else - err $test "content of /proc/sys/crypto/fips_enabled is not 1." - fi + if [ -f /proc/sys/crypto/fips_enabled ]; then + fips_enabled=$(cat /proc/sys/crypto/fips_enabled) + if [ "${fips_enabled}" = "1" ]; then + echo "FIPS is enabled." else - err $test "FIPS is not enabled." + err $test "content of /proc/sys/crypto/fips_enabled is not 1." fi + else + err $test "FIPS is not enabled." + fi - if [ ${os_version} = "20.04" ]; then - kernel=$(uname -r) - if [ -f /usr/src/linux-headers-${kernel}/Makefile ]; then - echo "fips header files exist." - else - err $test "fips header files don't exist." - fi + if [ "${os_version}" = "20.04" ]; then + kernel=$(uname -r) + if [ -f /usr/src/linux-headers-${kernel}/Makefile ]; then + echo "fips header files exist." + else + err $test "fips header files don't exist." fi + fi - if [ "${OS_SKU}" = "AzureContainerLinux" ]; then - if [ -f /etc/system-fips ]; then - echo "/etc/system-fips marker file exists." - else - err $test "/etc/system-fips marker file does not exist." - fi - if [ -f /boot/EFI/Linux/acl.efi.extra.d/fips.addon.efi ]; then - echo "ACL FIPS UKI addon file exists in active ESP location." - else - err $test "ACL FIPS UKI addon file does not exist in active ESP location." - fi + if [ "${OS_SKU}" = "AzureContainerLinux" ]; then + if [ -f /etc/system-fips ]; then + echo "/etc/system-fips marker file exists." + else + err $test "/etc/system-fips marker file does not exist." fi - - # Verify OpenSSL has a FIPS or SymCrypt provider loaded and active. - # This caught the AzureLinux V3 FIPS regression (ICM 51000001009688) where - # the kernel FIPS flag was set but the OpenSSL provider was missing, - # causing /opt/cni/bin/portmap to panic at runtime. - # - # Only run the providers check on OpenSSL 3.x (the providers concept doesn't exist - # in 1.1.x). Skip on 1.1.x (legacy FIPS module). Any other version is unexpected on - # a FIPS VHD and must be a hard failure so new images don't silently lose coverage. - # Keep this in sync with the Go validator in e2e/validators.go. - if ! command -v openssl >/dev/null 2>&1; then - err $test "openssl binary not found on a FIPS-enabled VHD." + if [ -f /boot/EFI/Linux/acl.efi.extra.d/fips.addon.efi ]; then + echo "ACL FIPS UKI addon file exists in active ESP location." else - openssl_version_raw=$(openssl version 2>&1) - openssl_version=$(echo "${openssl_version_raw}" | awk '{print $2}') - case "${openssl_version}" in - "") - err $test "could not parse openssl version (raw output: '${openssl_version_raw}')." - ;; - 3.*) + err $test "ACL FIPS UKI addon file does not exist in active ESP location." + fi + fi + + # Verify OpenSSL has a FIPS or SymCrypt provider loaded and active. + # This caught the AzureLinux V3 FIPS regression (ICM 51000001009688) where + # the kernel FIPS flag was set but the OpenSSL provider was missing, + # causing /opt/cni/bin/portmap to panic at runtime. + # + # Only run the providers check on OpenSSL 3.x (the providers concept doesn't exist + # in 1.1.x). Skip on 1.1.x (legacy FIPS module). Any other version is unexpected on + # a FIPS VHD and must be a hard failure so new images don't silently lose coverage. + # Keep this in sync with the Go validator in e2e/validators.go. + if ! command -v openssl >/dev/null 2>&1; then + err $test "openssl binary not found on a FIPS-enabled VHD." + echo "$test:Finish" + return + fi + openssl_version_raw=$(openssl version 2>&1) + openssl_version=$(echo "${openssl_version_raw}" | awk '{print $2}') + case "${openssl_version}" in + "") + err $test "could not parse openssl version (raw output: '${openssl_version_raw}')." + ;; + 3.*) providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" # Walk each provider block and record the status of fips/symcrypt providers. # This ensures `status: active` is tied to the fips/symcrypt block specifically - # and cannot be satisfied by another active provider (e.g. default). + # and cannot be satisfied by another active provider (e.g. default). The status + # line must be strictly more indented than its enclosing header. # Match by prefix so "symcrypt" covers both "symcrypt" and "symcryptprovider" # (the latter is what AzureLinux V3 / ACL FIPS images expose). + # Use `[ \t]` (matching the Go validator) rather than `[[:space:]]` so a trailing + # CR on CRLF-terminated remote output cannot break the header match; we also strip + # \r explicitly for defense in depth. fips_active="" current_provider="" + header_indent=0 while IFS= read -r line; do - # Provider header: leading whitespace (spaces or tabs) followed by the provider name + line="${line%$'\r'}" + # Provider header: leading spaces/tabs followed by the provider name # with no ':' (status/version lines are key:value). Tolerate variable indentation. # shellcheck disable=SC3010 - if [[ "${line}" =~ ^[[:space:]]+([^[:space:]:]+)[[:space:]]*$ ]]; then - current_provider="${BASH_REMATCH[1]}" + if [[ "${line}" =~ ^([\ $'\t']+)([^[:space:]:]+)[\ $'\t']*$ ]]; then + header_indent=${#BASH_REMATCH[1]} + current_provider="${BASH_REMATCH[2]}" continue fi # shellcheck disable=SC3010 if [[ "${current_provider}" == fips* || "${current_provider}" == symcrypt* ]] \ - && [[ "${line}" =~ status:[[:space:]]+active ]]; then - fips_active="${current_provider}" - break + && [[ "${line}" =~ ^([\ $'\t']+)status:[\ $'\t']+active ]]; then + # Require the status line to be strictly more indented than its header. + if [ ${#BASH_REMATCH[1]} -gt ${header_indent} ]; then + fips_active="${current_provider}" + break + fi fi done <<< "${providers_output}" if [ -n "${fips_active}" ]; then @@ -702,16 +715,14 @@ testFips() { else err $test "openssl does not have an active fips or symcrypt provider." fi - ;; - 1.1.*) - echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." - ;; - *) - err $test "unexpected openssl version '${openssl_version_raw}': FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported." - ;; - esac - fi - fi + ;; + 1.1.*) + echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." + ;; + *) + err $test "unexpected openssl version '${openssl_version_raw}': FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported." + ;; + esac echo "$test:Finish" } From 382b0ff96173f5cccce778c611fce09eb4e26b4e Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 22:48:58 -0700 Subject: [PATCH 16/19] fix(testFips): accept ACL VERSION_ID with date suffix (3.0.YYYYMMDD) ACL ships /etc/os-release VERSION_ID as e.g. 3.0.20260506. The exact-match allowlist 2.0|3.0 rejected ACL FIPS VHDs, breaking buildaclfipstlgen2 and buildaclarm64fipstlgen2 (ADO build 164284819). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vhdbuilder/packer/test/linux-vhd-content-test.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index a655ca57635..64ffd47b989 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -594,9 +594,10 @@ testFips() { # NOTE: although the pipeline passes pipeline-style OS_VERSION values such as # "V2"/"V3"/"OSGuardV3"/"acl" via $1, by the time this function runs the # global OS_VERSION has been overwritten by sourcing cse_helpers.sh, which - # sets it from /etc/os-release VERSION_ID (e.g. "2.0", "3.0", "20.04"). The - # call site `testFips $OS_VERSION ...` therefore passes the sourced value, - # not the pipeline value, so the allowlist below uses /etc/os-release values. + # sets it from /etc/os-release VERSION_ID (e.g. "2.0", "3.0", "3.0.20260506" + # on ACL, "20.04"). The call site `testFips $OS_VERSION ...` therefore passes + # the sourced value, not the pipeline value, so the allowlist below uses + # /etc/os-release values. os_version=$1 enable_fips=$2 @@ -612,8 +613,10 @@ testFips() { # NOTE: `err` reports to stderr (the runner greps stderr for failures) but does # not exit, so we explicitly `return` to avoid running the FIPS body afterwards # with an unsupported os_version. + # ACL ships VERSION_ID with a date suffix (e.g. "3.0.20260506"), so allow + # "3.0" and "3.0.*"; same for "2.0" defensively. case "${os_version}" in - 20.04|22.04|24.04|2.0|3.0) ;; + 20.04|22.04|24.04|2.0|2.0.*|3.0|3.0.*) ;; *) err $test "testFips invoked with enable_fips=true on unrecognized os_version '${os_version}'; add it to the allowlist." echo "$test:Finish" From fbb052d752b8c5c6f6bc78902061bbeff76b31b9 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Thu, 14 May 2026 23:04:39 -0700 Subject: [PATCH 17/19] refactor(validators): drop kernel-FIPS check from ValidateACLFIPSEnabled ValidateACLFIPSEnabled now only asserts the ACL-specific /etc/system-fips marker. Kernel FIPS (/proc/sys/crypto/fips_enabled == 1) is universal and already covered by ValidateFIPSProvider, which Test_ACLGen2FIPSTL calls immediately after. Removes the duplicated SSH round-trip and gives each helper a single, clear responsibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index 2b9c3e1372d..a6955abfd06 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -486,21 +486,13 @@ func ValidateFileExists(ctx context.Context, s *Scenario, fileName string) { } } -// ValidateACLFIPSEnabled asserts ACL-specific FIPS markers are present on the node. -// The kernel-FIPS check is intentionally kept here so callers using only this helper -// still get coverage. Callers that also invoke ValidateFIPSProvider will end up -// asserting `/proc/sys/crypto/fips_enabled == 1` twice; the cost is one extra SSH -// round-trip and is preferred over a fragile cross-helper contract. +// ValidateACLFIPSEnabled asserts ACL-specific FIPS markers are present on the node: +// the /etc/system-fips marker file written by vhdbuilder/scripts/linux/acl/tool_installs_acl.sh. +// Kernel FIPS mode (/proc/sys/crypto/fips_enabled == 1) is universal and is asserted by +// ValidateFIPSProvider; callers should compose the two validators when both are needed. func ValidateACLFIPSEnabled(ctx context.Context, s *Scenario) { s.T.Helper() ValidateFileExists(ctx, s, "/etc/system-fips") - execScriptOnVMForScenarioValidateExitCode( - ctx, - s, - `test "$(cat /proc/sys/crypto/fips_enabled)" = "1"`, - 0, - "expected /proc/sys/crypto/fips_enabled to be 1", - ) } func ValidateFileDoesNotExist(ctx context.Context, s *Scenario, fileName string) { From 768f6fefcab2c8246e204b4aa4f451315a618c14 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 15 May 2026 10:02:12 -0700 Subject: [PATCH 18/19] fix(ValidateFIPSProvider): make portmap panic check best-effort ACL FIPS TL VHDs do not deploy /opt/cni/bin/portmap by default (ACL ships the cni-plugins tarball staged under /opt/cni/downloads/ and only promotes binaries to /opt/cni/bin/ via installCNILegacy when bridge/host-local/ loopback are absent), so the hard 'must exist' precondition fails the validator on Test_ACLGen2FIPSTL even when FIPS posture is correct. Switch portmap to best-effort: skip with a log when not present, otherwise exec and assert no Go runtime panic markers. Kernel-FIPS and OpenSSL provider checks remain authoritative; portmap is corroborating only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/e2e/validators.go b/e2e/validators.go index a6955abfd06..1b2a0e8c38c 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -690,18 +690,27 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { s.T.Fatalf("unexpected openssl version %q: FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported", strings.TrimSpace(opensslVersion.stdout)) } - // 3. portmap must exist, be executable, and not panic when invoked. We assert the binary - // is present first so that a missing/non-executable portmap cannot silently pass the test, - // then exec it allowing the expected non-zero "usage" exit while checking for a panic trace. - // A Go runtime panic writes to stderr and exits non-zero; we capture both streams separately - // (without merging) and preserve the real exit code so a panic remains observable here. - // Match specific Go runtime failure markers — `panic:`, `fatal error:`, and the more - // distinctive `goroutine N [running]:` / `runtime.gopanic` traces — rather than the bare - // substring `runtime error:` which can appear in unrelated CNI plugin usage/help text and - // cause false failures. + // 3. portmap panic check (best-effort). The original FIPS regression manifested as + // /opt/cni/bin/portmap panicking when the OpenSSL FIPS provider was missing. Where + // portmap is present at the standard CNI path, exec it and assert no Go runtime + // panic markers in its output. On VHDs that don't deploy portmap there (e.g. ACL, + // which ships the cni-plugins tarball staged under /opt/cni/downloads/ and only + // promotes binaries to /opt/cni/bin/ via installCNILegacy when bridge/host-local/ + // loopback are absent), skip this corroborating step — checks 1 and 2 above are + // already authoritative for FIPS posture. A Go runtime panic writes to stderr and + // exits non-zero; we capture both streams separately (without merging) and preserve + // the real exit code so a panic remains observable here. Match specific Go runtime + // failure markers — `panic:`, `fatal error:`, and the more distinctive + // `goroutine N [running]:` / `runtime.gopanic` traces — rather than the bare + // substring `runtime error:` which can appear in unrelated CNI plugin usage/help + // text and cause false failures. portmapBin := "/opt/cni/bin/portmap" - execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("test -x %s", portmapBin), 0, - fmt.Sprintf("expected %s to exist and be executable", portmapBin)) + portmapPresent := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("test -x %s", portmapBin)) + if portmapPresent.exitCode != "0" { + s.T.Logf("portmap panic check skipped: %s not present or not executable on this VHD", portmapBin) + s.T.Logf("FIPS provider validation passed") + return + } portmap := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("%s < /dev/null", portmapBin)) panicMarkers := []*regexp.Regexp{ regexp.MustCompile(`(?m)^panic:`), From c2af5e0ffa5e037e5614d86af835c4468d509d7f Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 15 May 2026 13:00:18 -0700 Subject: [PATCH 19/19] cleanup: remove parser unit test and condense comments - Remove e2e/validators_test.go: the parser is e2e-only (no production impact), and synthetic inputs can drift from real OpenSSL output. The e2e FIPS scenarios remain the authoritative check. - Tighten verbose comments in e2e/validators.go and the testFips shell function to keep the rationale without restating obvious mechanics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/validators.go | 54 ++------ e2e/validators_test.go | 131 ------------------ .../packer/test/linux-vhd-content-test.sh | 53 +++---- 3 files changed, 31 insertions(+), 207 deletions(-) delete mode 100644 e2e/validators_test.go diff --git a/e2e/validators.go b/e2e/validators.go index 1b2a0e8c38c..bd5de6ba3e9 100644 --- a/e2e/validators.go +++ b/e2e/validators.go @@ -665,12 +665,8 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { require.Equal(s.T, "1", strings.TrimSpace(fipsEnabled.stdout), "expected /proc/sys/crypto/fips_enabled to be 1, got %q", fipsEnabled.stdout) // 2. OpenSSL provider must include an active fips or symcrypt provider on OpenSSL 3.x. - // Treat malformed or unrecognized `openssl version` output as a hard failure rather - // than silently skipping the check. FIPS VHDs are expected to ship OpenSSL 3.x except - // Ubuntu 20.04 which ships 1.1.x (legacy FIPS module, no providers concept). Any other - // version is unexpected and must fail loudly so we don't lose coverage on a future image. - // Merge stderr into stdout (`2>&1`) so we don't get an empty `stdout` and an unhelpful - // parse error if a future build of openssl prints its version banner to stderr. + // 1.1.x (Ubuntu 20.04 FIPS) uses the legacy FIPS module and is skipped. Merge stderr + // (`2>&1`) so a version banner written to stderr still parses. opensslVersion := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl version 2>&1", 0, "could not run openssl version") versionFields := strings.Fields(opensslVersion.stdout) require.GreaterOrEqual(s.T, len(versionFields), 2, @@ -679,31 +675,20 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { switch { case strings.HasPrefix(version, "3."): providers := execScriptOnVMForScenarioValidateExitCode(ctx, s, "openssl list -providers", 0, "could not list openssl providers") - // Accept any provider whose name starts with "fips" or "symcrypt" so we match - // "fips", "symcrypt", and "symcryptprovider" (the latter is what AzureLinux V3 / - // ACL FIPS images expose). See ICM 51000001009688. + // Prefix match so "symcrypt" covers AzureLinux V3 / ACL's "symcryptprovider". See ICM 51000001009688. require.True(s.T, opensslProviderActive(providers.stdout, "fips", "symcrypt"), "expected openssl to have an active fips or symcrypt provider, got:\n%s", providers.stdout) case strings.HasPrefix(version, "1.1."): s.T.Logf("openssl providers check skipped: detected version %q (legacy FIPS module)", strings.TrimSpace(opensslVersion.stdout)) default: - s.T.Fatalf("unexpected openssl version %q: FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported", strings.TrimSpace(opensslVersion.stdout)) + s.T.Fatalf("unexpected openssl version %q: FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x", strings.TrimSpace(opensslVersion.stdout)) } // 3. portmap panic check (best-effort). The original FIPS regression manifested as - // /opt/cni/bin/portmap panicking when the OpenSSL FIPS provider was missing. Where - // portmap is present at the standard CNI path, exec it and assert no Go runtime - // panic markers in its output. On VHDs that don't deploy portmap there (e.g. ACL, - // which ships the cni-plugins tarball staged under /opt/cni/downloads/ and only - // promotes binaries to /opt/cni/bin/ via installCNILegacy when bridge/host-local/ - // loopback are absent), skip this corroborating step — checks 1 and 2 above are - // already authoritative for FIPS posture. A Go runtime panic writes to stderr and - // exits non-zero; we capture both streams separately (without merging) and preserve - // the real exit code so a panic remains observable here. Match specific Go runtime - // failure markers — `panic:`, `fatal error:`, and the more distinctive - // `goroutine N [running]:` / `runtime.gopanic` traces — rather than the bare - // substring `runtime error:` which can appear in unrelated CNI plugin usage/help - // text and cause false failures. + // /opt/cni/bin/portmap panicking when the OpenSSL FIPS provider was missing. Skip if + // the binary isn't at the standard CNI path (e.g. ACL stages it under /opt/cni/downloads/); + // checks 1 and 2 are already authoritative. Match specific Go runtime panic markers + // rather than the bare substring `runtime error:` which appears in CNI usage text. portmapBin := "/opt/cni/bin/portmap" portmapPresent := execScriptOnVMForScenario(ctx, s, fmt.Sprintf("test -x %s", portmapBin)) if portmapPresent.exitCode != "0" { @@ -728,24 +713,18 @@ func ValidateFIPSProvider(ctx context.Context, s *Scenario) { s.T.Logf("FIPS provider validation passed") } -// Package-level regex compiled once at init so a bad pattern fails fast at startup -// rather than on first call. +// Package-level regex compiled once at init. var ( - // Provider headers are indented (typically two spaces, but tolerate tabs / extra padding) - // and have no key/value separator. Capture group 1 is the leading indent, group 2 is the - // provider name; we keep the indent so status lines below can be required to be strictly - // more indented than their enclosing header (matching the parser's "scoped to enclosing - // block" guarantee — same-indent `status:` would otherwise be mis-attributed). + // Provider header: indented (spaces or tabs) name with no key/value separator. + // Group 1 = indent (used to scope status lines to their block), group 2 = name. opensslProviderHeaderRE = regexp.MustCompile(`^([ \t]+)(\S+)\s*$`) opensslStatusLineRE = regexp.MustCompile(`^[ \t]+status:\s*(\S+)`) ) -// opensslProviderActive parses the output of `openssl list -providers` and returns true if -// any provider whose name has one of the given prefixes is reported with `status: active`. -// Prefix matching lets a single call cover related provider names (e.g. "symcrypt" matches -// both "symcrypt" and "symcryptprovider"). The status line is scoped to its enclosing -// provider block so an active default provider cannot mask an inactive fips/symcrypt provider. -// Status lines must be strictly more indented than the header that opened the block. +// opensslProviderActive parses `openssl list -providers` output and returns true if any +// provider whose name has one of the given prefixes is reported as `status: active`. +// The status line is scoped to its enclosing provider block (strictly more indented than +// the header) so an active default provider cannot mask an inactive fips/symcrypt one. func opensslProviderActive(output string, providerPrefixes ...string) bool { matches := func(name string) bool { for _, p := range providerPrefixes { @@ -758,8 +737,6 @@ func opensslProviderActive(output string, providerPrefixes ...string) bool { var current string var headerIndent int for _, line := range strings.Split(output, "\n") { - // Strip a trailing CR so CRLF-terminated remote output still matches the - // `\s*$` anchor in the header regex. line = strings.TrimRight(line, "\r") if m := opensslProviderHeaderRE.FindStringSubmatch(line); m != nil { headerIndent = len(m[1]) @@ -770,7 +747,6 @@ func opensslProviderActive(output string, providerPrefixes ...string) bool { continue } if m := opensslStatusLineRE.FindStringSubmatch(line); m != nil && m[1] == "active" { - // Require the status line to be strictly more indented than the header. leading := len(line) - len(strings.TrimLeft(line, " \t")) if leading > headerIndent { return true diff --git a/e2e/validators_test.go b/e2e/validators_test.go deleted file mode 100644 index 64c814178a6..00000000000 --- a/e2e/validators_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package e2e - -import "testing" - -// TestOpensslProviderActive exercises the parser used by ValidateFIPSProvider so we don't -// regress on real-world `openssl list -providers` output shapes — in particular, the -// AzureLinux V3 / ACL FIPS images expose the provider as "symcryptprovider" rather than -// "symcrypt" (ICM 51000001009688) and indentation has varied between distros. -func TestOpensslProviderActive(t *testing.T) { - cases := []struct { - name string - output string - prefixes []string - want bool - }{ - { - name: "symcryptprovider active on AzureLinux V3 matches symcrypt prefix", - output: `Providers: - default - name: OpenSSL Default Provider - version: 3.3.0 - status: active - symcryptprovider - name: SymCrypt Provider - version: 103.4.2 - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: true, - }, - { - name: "fips provider active", - output: `Providers: - default - status: active - fips - name: OpenSSL FIPS Provider - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: true, - }, - { - name: "symcrypt provider inactive, default active does not satisfy", - output: `Providers: - default - name: OpenSSL Default Provider - status: active - symcrypt - name: SymCrypt Provider - status: inactive -`, - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - { - name: "multiple active providers but no fips/symcrypt header at all", - output: `Providers: - default - name: OpenSSL Default Provider - status: active - legacy - name: OpenSSL Legacy Provider - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - { - // Regression guard for the "status scoped to enclosing block" guarantee: - // an inactive symcrypt block followed by an active default block must NOT - // be treated as symcrypt being active just because an "active" line appears - // later in the output. This is the most likely real-world misparse. - name: "interleaved: inactive symcrypt then active default does not satisfy", - output: `Providers: - symcrypt - name: SymCrypt Provider - status: inactive - default - name: OpenSSL Default Provider - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - { - name: "no fips or symcrypt provider listed", - output: `Providers: - default - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - { - name: "tolerates tab-indented provider header", - output: "Providers:\n\tsymcryptprovider\n\t\tstatus: active\n", - prefixes: []string{"fips", "symcrypt"}, - want: true, - }, - { - // Lock in: the literal "Providers:" header line should match neither the - // header regex (no leading whitespace) nor the status regex; an output with - // only that line and no providers below must return false. - name: "providers header alone with no entries", - output: "Providers:\n", - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - { - // Defensive: a `status: active` line at the same indent as the provider - // header must NOT satisfy the check — status lines are required to be - // strictly more indented than their enclosing block. - name: "status at same indent as header is not attributed", - output: `Providers: - symcrypt - status: active -`, - prefixes: []string{"fips", "symcrypt"}, - want: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := opensslProviderActive(tc.output, tc.prefixes...) - if got != tc.want { - t.Fatalf("opensslProviderActive() = %v, want %v\noutput:\n%s", got, tc.want, tc.output) - } - }) - } -} diff --git a/vhdbuilder/packer/test/linux-vhd-content-test.sh b/vhdbuilder/packer/test/linux-vhd-content-test.sh index 64ffd47b989..2c879f28a69 100644 --- a/vhdbuilder/packer/test/linux-vhd-content-test.sh +++ b/vhdbuilder/packer/test/linux-vhd-content-test.sh @@ -591,13 +591,9 @@ testChrony() { testFips() { local test="testFips" echo "$test:Start" - # NOTE: although the pipeline passes pipeline-style OS_VERSION values such as - # "V2"/"V3"/"OSGuardV3"/"acl" via $1, by the time this function runs the - # global OS_VERSION has been overwritten by sourcing cse_helpers.sh, which - # sets it from /etc/os-release VERSION_ID (e.g. "2.0", "3.0", "3.0.20260506" - # on ACL, "20.04"). The call site `testFips $OS_VERSION ...` therefore passes - # the sourced value, not the pipeline value, so the allowlist below uses - # /etc/os-release values. + # Sourcing cse_helpers.sh overwrites the global OS_VERSION from /etc/os-release + # VERSION_ID (e.g. "2.0", "3.0", "3.0.20260506" on ACL, "20.04"), so the value + # passed in as $1 is the /etc/os-release one, not the pipeline-style OS_VERSION. os_version=$1 enable_fips=$2 @@ -607,14 +603,10 @@ testFips() { return fi - # Known FIPS-capable os_version values. Adding a new distro that ships with - # FIPS enabled MUST be added here, otherwise the test fails loudly instead of - # silently no-op'ing (which is what motivated this guard — ICM 51000001009688). - # NOTE: `err` reports to stderr (the runner greps stderr for failures) but does - # not exit, so we explicitly `return` to avoid running the FIPS body afterwards - # with an unsupported os_version. - # ACL ships VERSION_ID with a date suffix (e.g. "3.0.20260506"), so allow - # "3.0" and "3.0.*"; same for "2.0" defensively. + # Known FIPS-capable VERSION_ID values. New FIPS-enabled distros MUST be added + # here; otherwise the test fails loudly instead of silently no-op'ing (ICM + # 51000001009688). ACL VERSION_ID carries a date suffix (e.g. "3.0.20260506"), + # hence the "3.0.*" entry. `err` writes to stderr only, so we `return` after. case "${os_version}" in 20.04|22.04|24.04|2.0|2.0.*|3.0|3.0.*) ;; *) @@ -657,15 +649,10 @@ testFips() { fi fi - # Verify OpenSSL has a FIPS or SymCrypt provider loaded and active. - # This caught the AzureLinux V3 FIPS regression (ICM 51000001009688) where - # the kernel FIPS flag was set but the OpenSSL provider was missing, - # causing /opt/cni/bin/portmap to panic at runtime. - # - # Only run the providers check on OpenSSL 3.x (the providers concept doesn't exist - # in 1.1.x). Skip on 1.1.x (legacy FIPS module). Any other version is unexpected on - # a FIPS VHD and must be a hard failure so new images don't silently lose coverage. - # Keep this in sync with the Go validator in e2e/validators.go. + # OpenSSL must have an active FIPS or SymCrypt provider on 3.x (ICM 51000001009688 + # was caused by kernel FIPS on with no provider, causing portmap to panic). Ubuntu + # 20.04 ships 1.1.x and uses the legacy FIPS module — skip there. Keep in sync with + # the Go validator in e2e/validators.go. if ! command -v openssl >/dev/null 2>&1; then err $test "openssl binary not found on a FIPS-enabled VHD." echo "$test:Finish" @@ -681,22 +668,15 @@ testFips() { providers_output=$(openssl list -providers 2>&1) echo "openssl list -providers output:" echo "${providers_output}" - # Walk each provider block and record the status of fips/symcrypt providers. - # This ensures `status: active` is tied to the fips/symcrypt block specifically - # and cannot be satisfied by another active provider (e.g. default). The status - # line must be strictly more indented than its enclosing header. - # Match by prefix so "symcrypt" covers both "symcrypt" and "symcryptprovider" - # (the latter is what AzureLinux V3 / ACL FIPS images expose). - # Use `[ \t]` (matching the Go validator) rather than `[[:space:]]` so a trailing - # CR on CRLF-terminated remote output cannot break the header match; we also strip - # \r explicitly for defense in depth. + # Walk each provider block, scoping `status: active` to its enclosing header. + # Prefix match so "symcrypt" covers AzureLinux V3 / ACL's "symcryptprovider". + # Use `[ \t]` (not `[[:space:]]`) so a trailing CR can't break header detection; + # also strip \r explicitly. fips_active="" current_provider="" header_indent=0 while IFS= read -r line; do line="${line%$'\r'}" - # Provider header: leading spaces/tabs followed by the provider name - # with no ':' (status/version lines are key:value). Tolerate variable indentation. # shellcheck disable=SC3010 if [[ "${line}" =~ ^([\ $'\t']+)([^[:space:]:]+)[\ $'\t']*$ ]]; then header_indent=${#BASH_REMATCH[1]} @@ -706,7 +686,6 @@ testFips() { # shellcheck disable=SC3010 if [[ "${current_provider}" == fips* || "${current_provider}" == symcrypt* ]] \ && [[ "${line}" =~ ^([\ $'\t']+)status:[\ $'\t']+active ]]; then - # Require the status line to be strictly more indented than its header. if [ ${#BASH_REMATCH[1]} -gt ${header_indent} ]; then fips_active="${current_provider}" break @@ -723,7 +702,7 @@ testFips() { echo "openssl providers check skipped: detected version '${openssl_version_raw}' (legacy FIPS module)." ;; *) - err $test "unexpected openssl version '${openssl_version_raw}': FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x; add explicit handling if a new branch is supported." + err $test "unexpected openssl version '${openssl_version_raw}': FIPS VHDs are expected to ship OpenSSL 3.x or 1.1.x." ;; esac