diff --git a/.ci/pipelines/env_variables.sh b/.ci/pipelines/env_variables.sh index 2fc82420aa..0e8f3b8f17 100755 --- a/.ci/pipelines/env_variables.sh +++ b/.ci/pipelines/env_variables.sh @@ -334,4 +334,10 @@ GITHUB_APP_CLIENT_SECRET_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_CLIENT_SECRET_HELM GITHUB_APP_WEBHOOK_URL_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_WEBHOOK_URL_HELM_PR_3) GITHUB_APP_WEBHOOK_SECRET_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_WEBHOOK_SECRET_HELM_PR_3) +# FIPS Custom CA Certificate (from Vault) +# Base64-encoded PEM certificate and private key for custom ingress certificates +# Used only by the FIPS job to configure custom CA-signed certificates for cluster ingress +FIPS_ROOT_CA_CERT=$(cat /tmp/secrets/FIPS_ROOT_CA_CERT) +FIPS_ROOT_CA_KEY=$(cat /tmp/secrets/FIPS_ROOT_CA_KEY) + set +a # Stop automatically exporting variables diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh new file mode 100644 index 0000000000..d158298fc7 --- /dev/null +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +# shellcheck source=.ci/pipelines/lib/log.sh +source "$DIR"/lib/log.sh +# shellcheck source=.ci/pipelines/lib/common.sh +source "$DIR"/lib/common.sh +# shellcheck source=.ci/pipelines/utils.sh +source "$DIR"/utils.sh +# shellcheck source=.ci/pipelines/lib/testing.sh +source "$DIR"/lib/testing.sh +# shellcheck source=.ci/pipelines/playwright-projects.sh +source "$DIR"/playwright-projects.sh + +handle_ocp_fips_helm() { + export NAME_SPACE="${NAME_SPACE:-showcase-fips-nightly}" + + common::oc_login + + K8S_CLUSTER_ROUTER_BASE=$(oc get route console -n openshift-console -o=jsonpath='{.spec.host}' | sed 's/^[^.]*\.//') + export K8S_CLUSTER_ROUTER_BASE + + cluster_setup_ocp_helm + + fips_deployment "${PW_PROJECT_SHOWCASE_FIPS}" + + deploy_test_backstage_customization_provider "${NAME_SPACE}" + + run_standard_deployment_tests +} + +# Same shape as base_deployment() in utils.sh, but merges diff-values_showcase-fips.yaml +# onto values_showcase.yaml, since base_deployment()/helm::install() are hardwired to +# the default values_showcase.yaml. +fips_deployment() { + common::require_vars "RELEASE_NAME" "TAG_NAME" "IMAGE_REGISTRY" "IMAGE_REPO" "K8S_CLUSTER_ROUTER_BASE" || return 1 + local artifacts_subdir=$1 + local fips_diff_value_file="${DIR}/value_files/diff-values_showcase-fips.yaml" + local fips_merged_value_file="/tmp/merged-values_showcase-fips.yaml" + + namespace::configure "${NAME_SPACE}" + + deploy_redis_cache "${NAME_SPACE}" + + cd "${DIR}" || exit + local rhdh_base_url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + apply_yaml_files "${DIR}" "${NAME_SPACE}" "${rhdh_base_url}" + + helm::merge_values "overwrite" "${DIR}/value_files/${HELM_CHART_VALUE_FILE_NAME}" "${fips_diff_value_file}" "${fips_merged_value_file}" + common::save_artifact "${artifacts_subdir}" "${fips_merged_value_file}" || true + + log::info "Deploying image from repository: ${IMAGE_REGISTRY}/${IMAGE_REPO}, TAG_NAME: ${TAG_NAME}, in NAME_SPACE: ${NAME_SPACE}" + # shellcheck disable=SC2046 + helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ + "${HELM_CHART_URL}" --version "${CHART_VERSION}" \ + -f "${fips_merged_value_file}" \ + --set global.clusterRouterBase="${K8S_CLUSTER_ROUTER_BASE}" \ + $(helm::get_image_params) +} + +# Verify that the OpenShift cluster has FIPS mode enabled +# Returns: +# 0 - FIPS is enabled +# 1 - FIPS is not enabled or cannot be determined +verify_cluster_fips_enabled() { + log::info "Verifying OpenShift cluster FIPS configuration..." + + local install_config + install_config=$(oc get cm cluster-config-v1 -n kube-system -o jsonpath='{.data.install-config}' 2> /dev/null) + + if [[ -z "${install_config}" ]]; then + log::error "Failed to retrieve cluster install-config from kube-system/cluster-config-v1" + return 1 + fi + + if echo "${install_config}" | grep -q "fips: true"; then + log::success "✓ Cluster FIPS mode: ENABLED" + return 0 + else + log::error "✗ Cluster FIPS mode: DISABLED (expected 'fips: true' in install-config)" + log::info "Install config excerpt:" + echo "${install_config}" | grep -A2 -B2 "fips" || echo "${install_config}" | head -10 + return 1 + fi +} + +run_standard_deployment_tests() { + local url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + + # Verify cluster FIPS mode is enabled + verify_cluster_fips_enabled || { + log::error "Cluster FIPS verification failed - this job requires a FIPS-enabled cluster" + return 1 + } + + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" +} + +# Configure custom CA certificate for OpenShift Ingress Controller +# This function generates a wildcard certificate signed by a custom root CA +# and patches the default IngressController to use it. +# +# Required environment variables: +# FIPS_ROOT_CA_CERT - Base64-encoded root CA certificate (PEM format) +# FIPS_ROOT_CA_KEY - Base64-encoded root CA private key (PEM format) +# K8S_CLUSTER_ROUTER_BASE - Cluster router base domain (e.g., apps.example.com) +# +# Returns: +# 0 - Success +# 1 - Failure (missing vars, cert generation failed, or patch failed) +fips_configure_custom_ca_ingress() { + log::info "Configuring custom CA certificate for OpenShift Ingress..." + + # Verify required environment variables + if [[ -z "${FIPS_ROOT_CA_CERT:-}" ]] || [[ -z "${FIPS_ROOT_CA_KEY:-}" ]]; then + log::warning "FIPS_ROOT_CA_CERT or FIPS_ROOT_CA_KEY not set - skipping custom CA configuration" + return 0 + fi + + if [[ -z "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then + log::error "K8S_CLUSTER_ROUTER_BASE is not set - cannot determine cluster domain" + return 1 + fi + + local wildcard_domain="*.${K8S_CLUSTER_ROUTER_BASE}" + local secret_name="custom-certs-default" + local ingress_namespace="openshift-ingress" + local tmpdir + tmpdir=$(mktemp -d) + + # Ensure cleanup on exit + trap 'rm -rf "${tmpdir}"' EXIT + + log::info "Generating wildcard certificate for domain: ${wildcard_domain}" + + # Write CA cert and key to temporary files + echo "${FIPS_ROOT_CA_CERT}" | base64 -d > "${tmpdir}/rootCA.crt" + echo "${FIPS_ROOT_CA_KEY}" | base64 -d > "${tmpdir}/rootCA.key" + + # Generate ECDSA P-256 key for wildcard certificate (FIPS-compliant) + openssl ecparam -name prime256v1 -genkey -noout -out "${tmpdir}/wildcard.key" + + # Generate CSR + openssl req -new -key "${tmpdir}/wildcard.key" \ + -out "${tmpdir}/wildcard.csr" \ + -subj "/O=CI-FIPS-Testing/CN=FIPS CI Ingress" + + # Create extensions file for v3 certificate + cat > "${tmpdir}/wildcard_ext.cnf" << EOF +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = issuer +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = ${wildcard_domain} +DNS.2 = ${K8S_CLUSTER_ROUTER_BASE} +EOF + + # Sign the CSR with the Root CA + openssl x509 -req -in "${tmpdir}/wildcard.csr" \ + -CA "${tmpdir}/rootCA.crt" -CAkey "${tmpdir}/rootCA.key" -CAcreateserial \ + -out "${tmpdir}/wildcard.crt" -days 30 -sha256 \ + -extfile "${tmpdir}/wildcard_ext.cnf" -extensions v3_req + + if [[ ! -f "${tmpdir}/wildcard.crt" ]]; then + log::error "Failed to generate wildcard certificate" + return 1 + fi + + log::success "✓ Wildcard certificate generated successfully" + + # Verify the certificate + local cert_subject + cert_subject=$(openssl x509 -in "${tmpdir}/wildcard.crt" -noout -subject) + log::info "Certificate subject: ${cert_subject}" + + # Create TLS secret in openshift-ingress namespace + log::info "Creating TLS secret '${secret_name}' in namespace '${ingress_namespace}'" + + # Delete existing secret if it exists + oc delete secret "${secret_name}" -n "${ingress_namespace}" --ignore-not-found=true + + # Create new secret + oc create secret tls "${secret_name}" \ + -n "${ingress_namespace}" \ + --cert="${tmpdir}/wildcard.crt" \ + --key="${tmpdir}/wildcard.key" + + if [[ $? -ne 0 ]]; then + log::error "Failed to create TLS secret in ${ingress_namespace}" + return 1 + fi + + log::success "✓ TLS secret '${secret_name}' created in namespace '${ingress_namespace}'" + + # Clean up temporary files immediately + rm -rf "${tmpdir}" + trap - EXIT + + # Patch the default IngressController to use the custom certificate + log::info "Patching default IngressController to use custom certificate..." + + oc patch ingresscontroller.operator default \ + -n openshift-ingress-operator \ + --type=merge \ + -p "{\"spec\":{\"defaultCertificate\":{\"name\":\"${secret_name}\"}}}" + + if [[ $? -ne 0 ]]; then + log::error "Failed to patch IngressController" + return 1 + fi + + log::success "✓ IngressController patched successfully" + + # Wait for the router deployment to roll out with new certificates + log::info "Waiting for router pods to restart with new certificates..." + + if ! oc rollout status deployment/router-default -n "${ingress_namespace}" --timeout=5m; then + log::warning "Router rollout did not complete within timeout - continuing anyway" + else + log::success "✓ Router pods restarted successfully" + fi + + log::success "Custom CA ingress configuration completed successfully" + return 0 +} diff --git a/.ci/pipelines/lib/config.sh b/.ci/pipelines/lib/config.sh index 8953790fef..73e9f73b88 100644 --- a/.ci/pipelines/lib/config.sh +++ b/.ci/pipelines/lib/config.sh @@ -59,6 +59,8 @@ config::select_config_map_file() { if [[ "${project}" == *rbac* ]]; then echo "$dir/resources/config_map/app-config-rhdh-rbac.yaml" + elif [[ "${project}" == *fips* ]]; then + echo "$dir/resources/config_map/app-config-rhdh-fips.yaml" else echo "$dir/resources/config_map/app-config-rhdh.yaml" fi diff --git a/.ci/pipelines/openshift-ci-tests.sh b/.ci/pipelines/openshift-ci-tests.sh index 3ffc4f94c5..0dabba0e3b 100755 --- a/.ci/pipelines/openshift-ci-tests.sh +++ b/.ci/pipelines/openshift-ci-tests.sh @@ -129,6 +129,13 @@ main() { log::info "Calling handle_ocp_localization" handle_ocp_localization ;; + *ocp*fips*helm*nightly*) + log::info "Sourcing ocp-fips-helm.sh" + # shellcheck source=.ci/pipelines/jobs/ocp-fips-helm.sh + source "${DIR}/jobs/ocp-fips-helm.sh" + log::info "Calling handle_ocp_fips_helm" + handle_ocp_fips_helm + ;; *ocp*helm*nightly*) log::info "Sourcing ocp-nightly.sh" # shellcheck source=.ci/pipelines/jobs/ocp-nightly.sh diff --git a/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml b/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml new file mode 100644 index 0000000000..b0b552205b --- /dev/null +++ b/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml @@ -0,0 +1,210 @@ +app: + support: + url: https://github.com/redhat-developer/rhdh/issues + items: + - title: Red Hat Developer Hub + links: + - url: https://access.redhat.com/products/red-hat-developer-hub + title: Product Information + baseUrl: ${RHDH_BASE_URL} + title: Red Hat Developer Hub + branding: + fullLogo: # QE Red Hat Developer Hub + light: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22160pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%20160%2080%22%3E%3Cg%20fill%3D%22%23000%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20letter-spacing%3D%220%22%3E%3Ctext%20font-family%3D%22Red%20Hat%20Display%22%20font-size%3D%2240%22%20font-weight%3D%22700%22%20transform%3D%22translate(-.177%2054.263)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3Ctext%20font-family%3D%22Red%20Hat%20Text%22%20font-size%3D%2214%22%20font-weight%3D%22700%22%20transform%3D%22translate(57.565%2035.73)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3ERed%20Hat%3C%2Ftspan%3E%3Ctspan%20x%3D%220%22%20dy%3D%2218.516%22%3EDeveloper%20Hub%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E" + dark: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22160pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%20160%2080%22%3E%3Cg%20fill%3D%22%23fff%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20letter-spacing%3D%220%22%3E%3Ctext%20font-family%3D%22Red%20Hat%20Display%22%20font-size%3D%2240%22%20font-weight%3D%22700%22%20transform%3D%22translate(-.177%2054.263)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3Ctext%20font-family%3D%22Red%20Hat%20Text%22%20font-size%3D%2214%22%20font-weight%3D%22700%22%20transform%3D%22translate(57.565%2035.73)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3ERed%20Hat%3C%2Ftspan%3E%3Ctspan%20x%3D%220%22%20dy%3D%2218.516%22%3EDeveloper%20Hub%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E" + iconLogo: # QE icon + light: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2280pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%2080%2080%22%3E%3Ctext%20fill%3D%22%23000%22%20font-family%3D%22Red%20Hat%20Mono%22%20font-size%3D%2264%22%20font-weight%3D%22700%22%20letter-spacing%3D%220%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20transform%3D%22translate(1.6%2062.813)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fsvg%3E" + dark: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2280pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%2080%2080%22%3E%3Ctext%20fill%3D%22%23fff%22%20font-family%3D%22Red%20Hat%20Mono%22%20font-size%3D%2264%22%20font-weight%3D%22700%22%20letter-spacing%3D%220%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20transform%3D%22translate(1.6%2062.813)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fsvg%3E" + theme: + light: + primaryColor: "#2A61A7" + headerColor1: "rgb(216, 98, 208)" + headerColor2: "rgb(216, 164, 98)" + navigationIndicatorColor: "rgb(98, 216, 105)" + palette: + rhdh: + general: + sidebarItemSelectedBackgroundColor: "#f0f0f0" + dark: + primaryColor: "#DC6ED9" + headerColor1: "rgb(190, 122, 45)" + headerColor2: "rgb(45, 190, 50)" + navigationIndicatorColor: "rgb(45, 113, 190)" + palette: + rhdh: + general: + sidebarItemSelectedBackgroundColor: "#333333" +backend: + baseUrl: ${RHDH_BASE_URL} + cors: + origin: ${RHDH_BASE_URL} + reading: + allow: + - host: "github.com" + - host: ${DH_TARGET_URL} + auth: + dangerouslyDisableDefaultAuthPolicy: true + externalAccess: + - type: static + options: + token: test-token + subject: test-subject + keys: + - secret: ${BACKEND_SECRET} + cache: + store: redis + connection: redis://${REDIS_USERNAME}:${REDIS_PASSWORD}@redis:6379 + # redis sets are no longer supported from 1.5 + # useRedisSets: true +integrations: + # Plugin: GitHub + github: + - host: github.com + apps: + - appId: ${GITHUB_APP_APP_ID} + clientId: ${GITHUB_APP_CLIENT_ID} + clientSecret: ${GITHUB_APP_CLIENT_SECRET} + webhookUrl: ${GITHUB_APP_WEBHOOK_URL} + webhookSecret: ${GITHUB_APP_WEBHOOK_SECRET} + privateKey: | + ${GITHUB_APP_PRIVATE_KEY} + - appId: ${GITHUB_APP_JANUS_TEST_APP_ID} + clientId: ${GITHUB_APP_JANUS_TEST_CLIENT_ID} + clientSecret: ${GITHUB_APP_JANUS_TEST_CLIENT_SECRET} + webhookUrl: ${GITHUB_APP_WEBHOOK_URL} + webhookSecret: ${GITHUB_APP_WEBHOOK_SECRET} + privateKey: | + ${GITHUB_APP_JANUS_TEST_PRIVATE_KEY} + bitbucketServer: + - host: bitbucket.com + apiBaseUrl: temp + username: temp + password: temp +auth: + # see https://backstage.io/docs/auth/ to learn about auth providers + environment: development + session: + secret: superSecretSecret + providers: + guest: + userEntityRef: user:default/guest + dangerouslyAllowOutsideDevelopment: true + google: + development: + clientId: ${GOOGLE_CLIENT_ID} + clientSecret: ${GOOGLE_CLIENT_SECRET} + github: + development: + clientSecret: ${GITHUB_OAUTH_APP_SECRET} + clientId: ${GITHUB_OAUTH_APP_ID} + callbackUrl: ${RHDH_BASE_URL}/api/auth/github/handler/frame + oidc: + development: + metadataUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth/realms/${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + clientSecret: ${KEYCLOAK_AUTH_CLIENT_SECRET} + prompt: auto + callbackUrl: ${RHDH_BASE_URL}/api/auth/oidc/handler/frame + signIn: + resolvers: + - resolver: emailLocalPartMatchingUserEntityName +signInPage: oidc +proxy: + skipInvalidProxies: true + # endpoints: {} + endpoints: + # Other Proxies + "/quay/api": + target: https://quay.io/ + headers: + X-Requested-With: "XMLHttpRequest" + changeOrigin: true + secure: true + "/add-test-header": + target: ${RHDH_BASE_URL_HTTP}/api/simple-chat + credentials: forward + headers: + "x-proxy-test-header": "hello!" +catalog: + processingInterval: { hours: 24 } + import: + entityFilename: catalog-info.yaml + # pullRequestBranchName: rhdh-integration + pullRequestBranchName: backstage-integration + rules: + - allow: [API, Component, Group, Location, Resource, System, Template] + locations: + - type: url + target: https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/all.yaml + - type: url + target: https://github.com/redhat-developer/red-hat-developer-hub-software-templates/blob/main/templates.yaml + - type: url + target: https://github.com/janus-qe/rhdh-test/blob/main/user.yml + rules: + - allow: [User] + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml + rules: + - allow: [User, Group] + providers: + # githubOrg: + # id: production + # githubUrl: "${GITHUB_URL}" + # orgs: ["${GITHUB_ORG}", "${GITHUB_ORG_2}"] + # Using Github GH_USER_ID account + keycloakOrg: + default: + baseUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth + loginRealm: ${KEYCLOAK_AUTH_LOGIN_REALM} + realm: ${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + clientSecret: ${KEYCLOAK_AUTH_CLIENT_SECRET} + schedule: + # Let's perform a single execution per test run + frequency: { hours: 24 } + timeout: { minutes: 1 } + badConfigForMetrics: + baseUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth + loginRealm: ${KEYCLOAK_AUTH_LOGIN_REALM} + realm: ${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + # Intentionally incorrect client secret for test purposes. + clientSecret: ABC + schedule: + # Let's perform a single execution to trigger the metrics fetch failure counter; next fetch will never happen again. + frequency: { hours: 24 } + timeout: { minutes: 1 } + initialDelay: { seconds: 15 } +dynatrace: + baseUrl: temp +argocd: + appLocatorMethods: + - type: "config" + instances: + - name: argoInstance1 + url: temp + token: temp + - name: argoInstance2 + url: temp + token: temp +permission: + enabled: false +buildInfo: + title: "RHDH Build info" + card: + TechDocs builder: "local" + Authentication provider: "Github" + RBAC: disabled + overrideBuildInfo: true +# Opt-out of database storage for user settings +userSettings: + persistence: browser +i18n: + locales: + - en + - de + - es + - fr + - it + - ja + defaultLocale: en diff --git a/.ci/pipelines/value_files/diff-values_showcase-fips.yaml b/.ci/pipelines/value_files/diff-values_showcase-fips.yaml new file mode 100644 index 0000000000..2d750a5edf --- /dev/null +++ b/.ci/pipelines/value_files/diff-values_showcase-fips.yaml @@ -0,0 +1,27 @@ +# This file is for FIPS installation only. +# It is applied on top of `values_showcase.yaml` via helm::merge_values (overwrite) and only +# contains complementary differences for FIPS. Note that it overwrites the whole key that is +# present in this file, so `upstream.backstage.extraEnvVars` must be restated in full even +# though only NODE_TLS_REJECT_UNAUTHORIZED changes (TLS verification must stay enabled in FIPS mode). +upstream: + backstage: + extraEnvVars: + - name: BACKEND_SECRET + valueFrom: + secretKeyRef: + key: backend-secret + name: '{{ include "rhdh.backend-secret-name" $ }}' + - name: POSTGRESQL_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + key: postgres-password + name: "{{ .Release.Name }}-postgresql" + # disable telemetry in CI + - name: SEGMENT_TEST_MODE + value: "true" + - name: NODE_TLS_REJECT_UNAUTHORIZED + value: "1" + - name: NODE_ENV + value: "production" + - name: ENABLE_CORE_ROOTHTTPROUTER_OVERRIDE + value: "true" diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 9f055740b8..a1cbea8391 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -90,6 +90,26 @@ export default defineConfig({ name: PW_PROJECT.SHOWCASE, timeout: 180 * 1000, dependencies: [PW_PROJECT.SMOKE_TEST], + testIgnore: [ + "**/playwright/seed.spec.ts", + "**/playwright/e2e/plugins/rbac/**/*.spec.ts", + "**/playwright/e2e/**/*-rbac.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts", + "**/playwright/e2e/auth-providers/**/*.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/configuration-test/config-map.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", + ], + }, + { + name: PW_PROJECT.SHOWCASE_FIPS, + timeout: 180 * 1000, + dependencies: [PW_PROJECT.SMOKE_TEST], + use: { + ignoreHTTPSErrors: false, + }, testIgnore: [ "**/playwright/seed.spec.ts", "**/playwright/e2e/plugins/rbac/**/*.spec.ts", @@ -119,6 +139,7 @@ export default defineConfig({ "**/playwright/e2e/auth-providers/github-happy-path.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], retries: 1, }, @@ -142,6 +163,7 @@ export default defineConfig({ "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], }, { @@ -167,6 +189,7 @@ export default defineConfig({ "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], }, { diff --git a/e2e-tests/playwright/e2e/fips-compliance.spec.ts b/e2e-tests/playwright/e2e/fips-compliance.spec.ts new file mode 100644 index 0000000000..05ca5cbf87 --- /dev/null +++ b/e2e-tests/playwright/e2e/fips-compliance.spec.ts @@ -0,0 +1,166 @@ +import { test, expect } from "@support/coverage/test"; +import * as tls from "tls"; +import * as url from "url"; + +test.describe("FIPS Compliance Validation", () => { + test.beforeAll(({}, testInfo) => { + testInfo.annotations.push({ + type: "component", + description: "fips", + }); + }); + + test("RHDH route uses FIPS-approved TLS cipher suite", async () => { + const baseUrl = process.env.BASE_URL; + if (typeof baseUrl !== "string" || baseUrl === "") { + throw new Error("BASE_URL environment variable is not set"); + } + + const parsedUrl = new url.URL(baseUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port === "" ? 443 : Math.trunc(Number(parsedUrl.port)); + + const tlsInfo = await new Promise<{ + cipher: string; + protocol: string; + authorized: boolean; + }>((resolve, reject) => { + const socket = tls.connect( + port, + host, + { + servername: host, + rejectUnauthorized: true, + }, + () => { + const cipher = socket.getCipher(); + const protocol = socket.getProtocol(); + const authorized = socket.authorized; + + socket.end(); + resolve({ + cipher: cipher.name, + protocol: protocol ?? "unknown", + authorized: authorized, + }); + }, + ); + + socket.on("error", (error) => { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + reject(new Error(`TLS connection failed: ${errorMessage}`)); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error("TLS connection timeout")); + }); + }); + + console.log(`[FIPS] Connected using cipher: ${tlsInfo.cipher}`); + console.log(`[FIPS] TLS protocol: ${tlsInfo.protocol}`); + console.log(`[FIPS] Certificate authorized: ${tlsInfo.authorized}`); + + expect(tlsInfo.cipher).toMatch(/AES/iu); + expect(tlsInfo.cipher).toMatch(/(GCM|CBC)/iu); + expect(tlsInfo.cipher).not.toMatch(/CHACHA20/iu); + expect(tlsInfo.cipher).not.toMatch(/3DES/iu); + expect(tlsInfo.cipher).not.toMatch(/RC4/iu); + expect(tlsInfo.protocol).toMatch(/TLSv1\.[2-3]/u); + }); + + test("RHDH route uses FIPS-approved certificate signature algorithm", async () => { + const baseUrl = process.env.BASE_URL; + if (typeof baseUrl !== "string" || baseUrl === "") { + throw new Error("BASE_URL environment variable is not set"); + } + + const parsedUrl = new url.URL(baseUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port === "" ? 443 : Math.trunc(Number(parsedUrl.port)); + + const certPem = await new Promise((resolve, reject) => { + const socket = tls.connect( + port, + host, + { + servername: host, + rejectUnauthorized: true, + }, + () => { + const cert = socket.getPeerCertificate(false); + + const certKeys = Object.keys(cert); + if (certKeys.length === 0) { + socket.end(); + reject(new Error("No certificate received from server")); + return; + } + + const rawCert = cert.raw; + if (typeof rawCert !== "object" || rawCert === null) { + socket.end(); + reject(new Error("Certificate raw data not available")); + return; + } + + const pemCert = rawCert.toString("base64"); + const pemLines = pemCert.match(/.{1,64}/gu); + if (pemLines === null || pemLines.length === 0) { + socket.end(); + reject(new Error("Failed to format certificate")); + return; + } + + const pem = `-----BEGIN CERTIFICATE-----\n${pemLines.join("\n")}\n-----END CERTIFICATE-----`; + + socket.end(); + resolve(pem); + }, + ); + + socket.on("error", (error) => { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + reject(new Error(`TLS connection failed: ${errorMessage}`)); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error("TLS connection timeout")); + }); + }); + + const { execSync } = await import("child_process"); + + const sigAlg = execSync("openssl x509 -noout -text", { + input: certPem, + encoding: "utf-8", + }) + .split("\n") + .find((line) => line.trim().startsWith("Signature Algorithm:")) + ?.split(":")[1] + ?.trim() ?? "unknown"; + + const subject = execSync("openssl x509 -noout -subject -nameopt RFC2253", { + input: certPem, + encoding: "utf-8", + }) + .replace("subject=", "") + .trim(); + + const issuer = execSync("openssl x509 -noout -issuer -nameopt RFC2253", { + input: certPem, + encoding: "utf-8", + }) + .replace("issuer=", "") + .trim(); + + console.log(`[FIPS] Certificate Subject: ${subject}`); + console.log(`[FIPS] Certificate Issuer: ${issuer}`); + console.log(`[FIPS] Certificate Signature Algorithm: ${sigAlg}`); + + expect(sigAlg.toLowerCase()).toMatch(/sha(256|384|512)/iu); + expect(sigAlg.toLowerCase()).not.toMatch(/md5/iu); + expect(sigAlg.toLowerCase()).not.toMatch(/sha1(?!with)/iu); + }); +}); diff --git a/e2e-tests/playwright/projects.json b/e2e-tests/playwright/projects.json index 567cc0098d..e324d53dac 100644 --- a/e2e-tests/playwright/projects.json +++ b/e2e-tests/playwright/projects.json @@ -1,6 +1,7 @@ { "SMOKE_TEST": "smoke-test", "SHOWCASE": "showcase", + "SHOWCASE_FIPS": "showcase-fips", "SHOWCASE_RBAC": "showcase-rbac", "ANY_TEST": "any-test", "SHOWCASE_K8S": "showcase-k8s", diff --git a/e2e-tests/playwright/projects.ts b/e2e-tests/playwright/projects.ts index 52751e0793..1aad0afffd 100644 --- a/e2e-tests/playwright/projects.ts +++ b/e2e-tests/playwright/projects.ts @@ -15,6 +15,7 @@ import projectsJson from "./projects.json" with { type: "json" }; export const PW_PROJECT = projectsJson as { readonly SMOKE_TEST: string; readonly SHOWCASE: string; + readonly SHOWCASE_FIPS: string; readonly SHOWCASE_RBAC: string; readonly ANY_TEST: string; readonly SHOWCASE_K8S: string;