diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 13d4053e..7790bcc6 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -46,6 +46,9 @@ jobs: vscode-docker: filters: - 'features/src/workbench-tools/**' + test-app-secrets: + filters: + - 'src/common/**' workbench-jupyter: template: custom-workbench-jupyter-template maximize_build_space: true diff --git a/feature-versions/state.json b/feature-versions/state.json index 8b8442d0..f10e955c 100644 --- a/feature-versions/state.json +++ b/feature-versions/state.json @@ -2,86 +2,91 @@ "ghcr.io/devcontainers/features/aws-cli": { "tag": "1", "installed": "sha256:17cb4a40151f59144b46957b9264683663b0214371a041ecd53dccc015a4b923", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/devcontainers/features/java": { "tag": "1", "installed": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/dhoeric/features/google-cloud-cli": { "tag": "1", "installed": "sha256:fa5d894718825c5ad8009ac8f2c9f0cea3d1661eb108a9d465cba9f3fc48965f", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/rocker-org/devcontainer-features/r-packages": { "tag": "1", "installed": "sha256:1a4ec64c4d2060e78e9c812bd3b3622c7e008465d566a2781c0e2b17a82592e5", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/devcontainers/features/common-utils": { "tag": "2", "installed": "sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/devcontainers/features/node": { "tag": "1", "installed": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "ghcr.io/anthropics/devcontainer-features/claude-code": { "tag": "1.0", "installed": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", - "filter": ".*\\/\\.devcontainer\\.json" + "filter": "src\\/.*\\/\\.devcontainer\\.json" }, "us-west2-docker.pkg.dev/shared-pub-buckets-94mvrf/workbench-artifacts/app-wondershaper": { "tag": "latest", "installed": "sha256:0438761b165f6f8da90383722278be8cf89607f39cd42c386877fe72f26b3b40", - "filter": ".*\\/docker-compose.yaml" + "filter": "src\\/.*\\/docker-compose.yaml" }, "us-west2-docker.pkg.dev/shared-pub-buckets-94mvrf/workbench-artifacts/app-jupyter-extension-builder": { "tag": "latest", "installed": "sha256:454db241e887998792175c600b9a04ef78cbf8cede9b75c92f09982cbbd7ae65", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" }, "us-west2-docker.pkg.dev/shared-pub-buckets-94mvrf/workbench-artifacts/app-aou-jupyter": { "tag": "latest", "installed": "sha256:91bf43412e28a0eeb057ac480f243ce105636be59b16b105cb9e2fcb5851f041", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" }, "us-west2-docker.pkg.dev/shared-pub-buckets-94mvrf/workbench-artifacts/app-workbench-jupyter": { "tag": "latest", "installed": "sha256:fe512afbcca4d0d112724bcefac8d36af1fd789c74ff912f1de507f2fc0b94ea", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" }, "ghcr.io/rocker-org/devcontainer/tidyverse": { "tag": "4", "installed": "sha256:289c5d02d8115aa209f4a8a49ee9378dccbf623897eed9cc46c87dfbbca9015b", - "filter": ".*\\/(docker-compose\\.yaml|Dockerfile)" + "filter": "src\\/.*\\/(docker-compose\\.yaml|Dockerfile)" }, "lscr.io/linuxserver/code-server": { "tag": "latest", "installed": "sha256:1f384394d473c43ab6a39b2227ba3aa9c95af648ce3a67e1b4da1969c16c7c0d", - "filter": ".*\\/(docker-compose\\.yaml|Dockerfile)" + "filter": "src\\/.*\\/(docker-compose\\.yaml|Dockerfile)" }, "golang": { - "tag": "1.23-bookworm", - "installed": "sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db", - "filter": ".*\\/Dockerfile" + "tag": "1.26-alpine", + "installed": "sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1", + "filter": "src\\/.*\\/Dockerfile" }, "nvcr.io/nvidia/nemo": { "tag": "25.07.nemotron-nano-v2", "installed": "sha256:f96daf8b2f07a4f8fb20e754f91b507e507ceb9119943027a4d43d7ca15e3896", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" }, "nvcr.io/nvidia/clara/clara-parabricks": { "tag": "4.6.0-1", "installed": "sha256:d0761eb4b9921bc046c53520287316d545eb79feaeb8f22387e9bb5734650447", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" }, "sosedoff/pgweb": { "tag": "latest", "installed": "sha256:a5256d416e2e8b92d69a4459058e3eca33a9f075d8325491644411d0bc3bd70b", - "filter": ".*\\/Dockerfile" + "filter": "src\\/.*\\/Dockerfile" + }, + "mikefarah/yq": { + "tag": "4", + "installed": "sha256:0cb4a78491b6e62ee8a9bf4fbeacbd15b5013d19bc420591b05383a696315e60", + "filter": "startupscript\\/butane\\/050-parse-devcontainer\\.sh" } } diff --git a/feature-versions/update.sh b/feature-versions/update.sh index 417a1db3..db31da73 100755 --- a/feature-versions/update.sh +++ b/feature-versions/update.sh @@ -6,7 +6,7 @@ set -o pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" -SRC_DIR="$(realpath "$SCRIPT_DIR/../src")" +ROOT_DIR="$(realpath "$SCRIPT_DIR/..")" STATE_FILE="$SCRIPT_DIR/state.json" readonly STATE_FILE STATE="$(cat "$STATE_FILE")" @@ -25,8 +25,8 @@ for IMAGE in $(echo "$STATE" | jq -r 'keys | .[]'); do if [ "$INSTALLED" != "$LATEST" ]; then echo "Updating $IMAGE from $INSTALLED to $LATEST" - pushd "$SRC_DIR" - find . -regextype posix-extended -regex "$FILTER" -print0 | xargs -0L1 sed -i "s|$INSTALLED|$LATEST|g" + pushd "$ROOT_DIR" + find . -regextype posix-extended -regex "\.\/$FILTER" -print0 | xargs -0L1 sed -i "s|$INSTALLED|$LATEST|g" popd NEW_STATE="$(jq --arg feat "$IMAGE" --arg latest "$LATEST_DIGEST" '.[$feat].installed = $latest' "$STATE_FILE")" diff --git a/src/aou-common/load-envs/Dockerfile b/src/aou-common/load-envs/Dockerfile index 81ee384e..f58f24a8 100644 --- a/src/aou-common/load-envs/Dockerfile +++ b/src/aou-common/load-envs/Dockerfile @@ -1,4 +1,4 @@ -FROM golang@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db +FROM golang@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 WORKDIR /source RUN --mount=type=bind,source=.,target=/source,rw \ mkdir -p /dist && \ diff --git a/src/common/common-compose.yaml b/src/common/common-compose.yaml new file mode 100644 index 00000000..4008ec95 --- /dev/null +++ b/src/common/common-compose.yaml @@ -0,0 +1,10 @@ +# This docker-compose file is meant to be included to build common image +# dependencies. Use the .devcontainer.json "runServices" property to prevent it +# from starting automatically. +# +# Paths are relative to the primary docker-compose.yaml file. + +services: + common-secret-receiver-builder: + build: + context: ../common/secret-receiver diff --git a/src/common/secret-receiver/Dockerfile b/src/common/secret-receiver/Dockerfile new file mode 100644 index 00000000..ab951584 --- /dev/null +++ b/src/common/secret-receiver/Dockerfile @@ -0,0 +1,7 @@ +FROM golang@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 +WORKDIR /source +RUN --mount=type=bind,source=.,target=/source,rw \ + mkdir -p /dist && \ + go build -v -o /dist/wb-secret-receiver . + +ENTRYPOINT ["echo", "This image is only a build layer and should not be run directly."] diff --git a/src/common/secret-receiver/go.mod b/src/common/secret-receiver/go.mod new file mode 100644 index 00000000..1e8ce8c9 --- /dev/null +++ b/src/common/secret-receiver/go.mod @@ -0,0 +1,5 @@ +module github.com/verily-src/workbench-app-devcontainers/src/common/secret-receiver + +go 1.25.0 + +require golang.org/x/sys v0.43.0 diff --git a/src/common/secret-receiver/go.sum b/src/common/secret-receiver/go.sum new file mode 100644 index 00000000..71016e3d --- /dev/null +++ b/src/common/secret-receiver/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/src/common/secret-receiver/main.go b/src/common/secret-receiver/main.go new file mode 100644 index 00000000..57a17c8a --- /dev/null +++ b/src/common/secret-receiver/main.go @@ -0,0 +1,165 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "golang.org/x/sys/unix" +) + +// Linux pipe buffer capacity; writes up to this size complete without blocking. +const maxPipeSecretSize = 65536 + +const ( + SecretTypePipeVar = "pipeVar" + SecretTypePathVar = "pathVar" + SecretTypeValueVar = "valueVar" +) + +type Secret struct { + Type string `json:"type"` + Value string `json:"value"` + Target string `json:"target"` +} + +func getSecrets() ([]Secret, error) { + pipePath := "/tmp/secrets" + if err := unix.Mkfifo(pipePath, 0600); err != nil { + return nil, err + } + defer os.Remove(pipePath) + + fmt.Printf("Waiting for secrets to be written to named pipe at %s...\n", pipePath) + file, err := os.OpenFile(pipePath, os.O_RDONLY, os.ModeNamedPipe) + if err != nil { + return nil, err + } + defer file.Close() + + var secrets []Secret + decoder := json.NewDecoder(file) + if err := decoder.Decode(&secrets); err != nil { + return nil, err + } + + return secrets, nil +} + +func writeSecretToPipe(secret Secret) (string, error) { + if len(secret.Value) > maxPipeSecretSize { + return "", fmt.Errorf("secret for %s exceeds pipe buffer size (%d > %d)", secret.Target, len(secret.Value), maxPipeSecretSize) + } + + fds := make([]int, 2) + if err := unix.Pipe(fds); err != nil { + return "", err + } + readFd, writeFd := fds[0], fds[1] + + if _, err := unix.Write(writeFd, []byte(secret.Value)); err != nil { + unix.Close(readFd) + unix.Close(writeFd) + return "", err + } + unix.Close(writeFd) + + return fmt.Sprintf("/dev/fd/%d", readFd), nil +} + +func writeSecretToMemfd(secret Secret) (path string, err error) { + fd, err := unix.MemfdCreate(secret.Target, 0) + if err != nil { + return "", err + } + defer func() { + if err != nil { + unix.Close(fd) + } + }() + + if _, err = unix.Write(fd, []byte(secret.Value)); err != nil { + return "", err + } + + if _, err = unix.Seek(fd, 0, unix.SEEK_SET); err != nil { + return "", err + } + + return fmt.Sprintf("/dev/fd/%d", fd), nil +} + +func buildSecretEnvVars(secrets []Secret) ([]string, error) { + var envVars []string + for _, secret := range secrets { + switch secret.Type { + case SecretTypePipeVar: + secretPath, err := writeSecretToPipe(secret) + if err != nil { + return nil, fmt.Errorf("writing secret to pipe for %s: %w", secret.Target, err) + } + envVars = append(envVars, fmt.Sprintf("%s=%s", secret.Target, secretPath)) + case SecretTypePathVar: + secretPath, err := writeSecretToMemfd(secret) + if err != nil { + return nil, fmt.Errorf("writing secret to memfd for %s: %w", secret.Target, err) + } + envVars = append(envVars, fmt.Sprintf("%s=%s", secret.Target, secretPath)) + case SecretTypeValueVar: + envVars = append(envVars, fmt.Sprintf("%s=%s", secret.Target, secret.Value)) + default: + return nil, fmt.Errorf("unknown secret type %s for target %s", secret.Type, secret.Target) + } + } + + return envVars, nil +} + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: %s [args...]\n", os.Args[0]) + os.Exit(1) +} + +func main() { + // Retrieve subcommand and arguments + args := os.Args[1:] + if len(args) < 1 { + usage() + } + + // Validate command before waiting for secrets + if strings.Contains(args[0], " ") { + args = append([]string{"sh", "-c"}, args...) + } + + cmdPath, err := exec.LookPath(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error finding command %s: %v\n", args[0], err) + os.Exit(1) + } + args[0] = cmdPath + + // Get secrets from named pipe + secrets, err := getSecrets() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting secrets: %v\n", err) + os.Exit(1) + } + + secretEnvVars, err := buildSecretEnvVars(secrets) + if err != nil { + fmt.Fprintf(os.Stderr, "Error building secret env vars: %v\n", err) + os.Exit(1) + } + + // Replace current process with the specified command + env := append(os.Environ(), secretEnvVars...) + if err := unix.Exec(cmdPath, args, env); err != nil { + fmt.Fprintf(os.Stderr, "Error executing command: %v\n", err) + os.Exit(1) + } +} diff --git a/src/test-app-secrets/.devcontainer.json b/src/test-app-secrets/.devcontainer.json new file mode 100644 index 00000000..5026c999 --- /dev/null +++ b/src/test-app-secrets/.devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "test-app-secrets", + "dockerComposeFile": "docker-compose.yaml", + "service": "app", + "runServices": ["app"], + "shutdownAction": "none", + "workspaceFolder": "/workspace", + "remoteUser": "root" +} diff --git a/src/test-app-secrets/Dockerfile b/src/test-app-secrets/Dockerfile new file mode 100644 index 00000000..9e593995 --- /dev/null +++ b/src/test-app-secrets/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3-slim + +COPY --from=wb-secret-receiver /dist/wb-secret-receiver /wb-secret-receiver diff --git a/src/test-app-secrets/devcontainer-template.json b/src/test-app-secrets/devcontainer-template.json new file mode 100644 index 00000000..5db1ff25 --- /dev/null +++ b/src/test-app-secrets/devcontainer-template.json @@ -0,0 +1,20 @@ +{ + "id": "test-app-secrets", + "version": "1.0.0", + "name": "test-app-secrets", + "description": "Test app demonstrating secret receiver integration", + "options": { + "cloud": { + "type": "string", + "enum": ["gcp", "aws"], + "default": "gcp", + "description": "Cloud provider (gcp or aws)" + }, + "login": { + "type": "string", + "description": "Whether to log in to workbench CLI", + "proposals": ["true", "false"], + "default": "false" + } + } +} diff --git a/src/test-app-secrets/docker-compose.yaml b/src/test-app-secrets/docker-compose.yaml new file mode 100644 index 00000000..a9707eed --- /dev/null +++ b/src/test-app-secrets/docker-compose.yaml @@ -0,0 +1,26 @@ +include: + - ../common/common-compose.yaml +services: + app: + container_name: "application-server" + build: + context: . + additional_contexts: + wb-secret-receiver: service:common-secret-receiver-builder + entrypoint: ["/wb-secret-receiver", "python3", "-m", "http.server", "8080"] + restart: always + volumes: + - .:/workspace:cached + ports: + - 8080:8080 + networks: + - app-network + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined +networks: + app-network: + external: true diff --git a/src/test-app-secrets/secrets.yml b/src/test-app-secrets/secrets.yml new file mode 100644 index 00000000..c7615426 --- /dev/null +++ b/src/test-app-secrets/secrets.yml @@ -0,0 +1,10 @@ +secrets: + - name: "example-secret" + valueVar: "EXAMPLE_SECRET" + - name: "pipe-secret" + pipeVar: "PIPE_SECRET" + - name: "path-secret" + pathVar: "PATH_SECRET" + - name: "multi-dest-secret" + valueVar: "MULTI_VALUE" + pathVar: "MULTI_PATH" diff --git a/startupscript/butane/050-parse-devcontainer.sh b/startupscript/butane/050-parse-devcontainer.sh index ee15ba06..012b8f6a 100755 --- a/startupscript/butane/050-parse-devcontainer.sh +++ b/startupscript/butane/050-parse-devcontainer.sh @@ -170,3 +170,12 @@ readonly JSONC_STRIP_COMMENTS=/home/core/jsoncStripComments.mjs DEVCONTAINER_CUSTOMIZATIONS="$("${JSONC_STRIP_COMMENTS}" < "${DEVCONTAINER_CONFIG_PATH}" | jq -c .customizations.workbench)" readonly DEVCONTAINER_CUSTOMIZATIONS set_metadata 'devcontainer/customizations' "${DEVCONTAINER_CUSTOMIZATIONS}" + +# Convert secrets.yml to JSON for use by credential helpers and provide-secrets +rm -f /home/core/secrets.json +SECRETS_YML="${DEVCONTAINER_PATH}/secrets.yml" +if [[ -f "${SECRETS_YML}" ]]; then + echo "Converting ${SECRETS_YML} to /home/core/secrets.json" + docker run --rm -v "${SECRETS_YML}:/secrets.yml:ro" \ + mikefarah/yq@sha256:0cb4a78491b6e62ee8a9bf4fbeacbd15b5013d19bc420591b05383a696315e60 -o=json '.secrets' /secrets.yml > /home/core/secrets.json +fi diff --git a/startupscript/butane/055-provide-secrets.sh b/startupscript/butane/055-provide-secrets.sh new file mode 100755 index 00000000..37cb18ea --- /dev/null +++ b/startupscript/butane/055-provide-secrets.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# provide-secrets.sh +# +# Reads secrets.json (created by parse-devcontainer.sh), retrieves each secret +# value from WSM using the challenge/response signing protocol, and delivers +# them to the app container via a named pipe. +# +# Runs on the VM host after devcontainer up and before start-proxy-agent. +# Pairs with the entrypoint binary inside the container, which creates a +# named pipe at /tmp/secrets, blocks until this script writes to it, then +# exposes secrets as environment variables or file descriptors. +# +# Usage: +# ./provide-secrets.sh +# +# Prerequisites: +# - /home/core/secrets.json (created by parse-devcontainer.sh) +# - /home/core/metadata-utils.sh, /home/core/service-utils.sh, /home/core/secret-utils.sh +# - Workbench CLI configured (configure-wb.sh) +# - Signing key registered (register-key.sh) +# - openssl, jq + +set -o errexit +set -o nounset +set -o pipefail +set -o xtrace + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +readonly CLOUD="$1" +readonly SECRETS_JSON_FILE="/home/core/secrets.json" + +if [[ ! -f "${SECRETS_JSON_FILE}" ]]; then + echo "No secrets.json found. Skipping secret provisioning." + exit 0 +fi + +# --- Check for pipe-deliverable secrets early --- +PIPE_SECRETS="$(jq '[.[] | select(.pipeVar or .pathVar or .valueVar)]' "${SECRETS_JSON_FILE}")" +readonly PIPE_SECRETS + +PIPE_SECRET_COUNT="$(echo "${PIPE_SECRETS}" | jq 'length')" +readonly PIPE_SECRET_COUNT + +if [[ "${PIPE_SECRET_COUNT}" -eq 0 ]]; then + echo "No pipe-deliverable secrets. Skipping." + exit 0 +fi + +echo "Found ${PIPE_SECRET_COUNT} pipe-deliverable secret(s)." + +# --- WSM setup --- + +# shellcheck source=/dev/null +source /home/core/metadata-utils.sh +# shellcheck source=/dev/null +source /home/core/service-utils.sh +# shellcheck source=/dev/null +source /home/core/secret-utils.sh + +CLI_SERVER="$(get_metadata_value "terra-cli-server" "prod")" +readonly CLI_SERVER + +WSM_URL="$(get_service_url "wsm" "${CLI_SERVER}")" +readonly WSM_URL + +WORKSPACE_UFID="$(get_metadata_value "terra-workspace-id" "")" +readonly WORKSPACE_UFID +if [[ -z "${WORKSPACE_UFID}" ]]; then + echo "No workspace ID found in metadata. Skipping secret provisioning." + exit 0 +fi + +RESOURCE_ID="$(get_metadata_value "wb-resource-id" "")" +readonly RESOURCE_ID +if [[ -z "${RESOURCE_ID}" ]]; then + echo "No resource ID found in metadata. Skipping secret provisioning." + exit 0 +fi + +set +o xtrace +TOKEN="$(/home/core/wb.sh auth print-access-token)" +# shellcheck disable=SC2034 +readonly TOKEN +set -o xtrace + +WORKSPACE_ID="$(curl_with_auth TOKEN -s -f \ + "${WSM_URL}/api/workspaces/v1/workspaceByUserFacingId/${WORKSPACE_UFID}" \ + | jq -r '.id')" +readonly WORKSPACE_ID +if [[ -z "${WORKSPACE_ID}" || "${WORKSPACE_ID}" == "null" ]]; then + >&2 echo "ERROR: Failed to resolve workspace UUID for '${WORKSPACE_UFID}'." + exit 1 +fi + +readonly KEY_FILE="/home/core/signing-key/signing.key" +if [[ ! -f "${KEY_FILE}" ]]; then + >&2 echo "ERROR: Signing key not found at ${KEY_FILE}. Was register-key.sh run?" + exit 1 +fi + +readonly CONTAINER_NAME="application-server" +readonly PIPE_PATH="/tmp/secrets" + +# --- Fetch the app resource to get attached secrets map --- +if [[ "${CLOUD}" == "gcp" ]]; then + RESOURCE_PATH="resources/controlled/gcp/gce-instances" +elif [[ "${CLOUD}" == "aws" ]]; then + RESOURCE_PATH="resources/controlled/aws/instances" +else + >&2 echo "ERROR: Unsupported cloud: ${CLOUD}" + exit 1 +fi +readonly RESOURCE_PATH + +APP_RESOURCE="$(curl_with_auth TOKEN -s -f \ + "${WSM_URL}/api/workspaces/v1/${WORKSPACE_ID}/${RESOURCE_PATH}/${RESOURCE_ID}")" +readonly APP_RESOURCE + +ATTACHED_SECRETS="$(echo "${APP_RESOURCE}" | jq '.attributes.secrets // {}')" +readonly ATTACHED_SECRETS + +echo "Waiting for container to create named pipe..." + +retries=0 +until docker exec "${CONTAINER_NAME}" sh -c "[ -p ${PIPE_PATH} ]" 2>/dev/null; do + if (( retries >= 40 )); then + >&2 echo "ERROR: Timed out waiting for container to create ${PIPE_PATH}" + exit 1 + fi + sleep 3 + (( retries++ )) +done + +# --- Build JSON secrets array for pipe delivery --- +SECRETS_JSON="[]" + +for i in $(seq 0 $((PIPE_SECRET_COUNT - 1))); do + SECRET_ENTRY="$(echo "${PIPE_SECRETS}" | jq ".[$i]")" + SECRET_NAME="$(echo "${SECRET_ENTRY}" | jq -r '.name')" + + # Look up secret's workspace and resource IDs from attached secrets map + SECRET_WORKSPACE_ID="$(echo "${ATTACHED_SECRETS}" | jq -r --arg name "${SECRET_NAME}" '.[$name].workspaceId')" + SECRET_RESOURCE_ID="$(echo "${ATTACHED_SECRETS}" | jq -r --arg name "${SECRET_NAME}" '.[$name].resourceId')" + + if [[ -z "${SECRET_WORKSPACE_ID}" || "${SECRET_WORKSPACE_ID}" == "null" || \ + -z "${SECRET_RESOURCE_ID}" || "${SECRET_RESOURCE_ID}" == "null" ]]; then + >&2 echo "ERROR: Secret '${SECRET_NAME}' not found in app resource's attached secrets." + exit 1 + fi + + validate_allowed_secret "${SECRET_ENTRY}" "${SECRET_WORKSPACE_ID}" "${SECRET_RESOURCE_ID}" + + echo "Retrieving secret: ${SECRET_NAME}" + + { set +o xtrace; } 2>/dev/null + SECRET_VALUE="$(retrieve_secret TOKEN "${WSM_URL}" "${RESOURCE_ID}" "${KEY_FILE}" \ + "${SECRET_WORKSPACE_ID}" "${SECRET_RESOURCE_ID}")" + + for SECRET_TYPE_KEY in pipeVar pathVar valueVar; do + SECRET_TARGET="$(echo "${SECRET_ENTRY}" | jq -r ".${SECRET_TYPE_KEY} // empty")" + if [[ -n "${SECRET_TARGET}" ]]; then + SECRETS_JSON="$(echo "${SECRETS_JSON}" | jq \ + --arg type "${SECRET_TYPE_KEY}" \ + --arg value "${SECRET_VALUE}" \ + --arg target "${SECRET_TARGET}" \ + '. += [{"type": $type, "value": $value, "target": $target}]')" + fi + done + set -o xtrace + + echo "Retrieved secret: ${SECRET_NAME}" +done + +echo "Delivering ${PIPE_SECRET_COUNT} secret(s) to container..." + +set +o xtrace +if ! echo "${SECRETS_JSON}" | timeout 30 docker exec -i "${CONTAINER_NAME}" sh -c "cat > ${PIPE_PATH}"; then + >&2 echo "ERROR: Timed out writing secrets to container pipe." + exit 1 +fi + +echo "Secrets delivered successfully." diff --git a/startupscript/butane/aws/docker-credential-workbench-secret b/startupscript/butane/aws/docker-credential-workbench-secret new file mode 100755 index 00000000..4c07ee62 --- /dev/null +++ b/startupscript/butane/aws/docker-credential-workbench-secret @@ -0,0 +1,2 @@ +#!/bin/bash +/home/core/docker-credential-secrets.sh "resources/controlled/aws/instances" "$@" diff --git a/startupscript/butane/docker-auth-secrets.sh b/startupscript/butane/docker-auth-secrets.sh new file mode 100755 index 00000000..6823af96 --- /dev/null +++ b/startupscript/butane/docker-auth-secrets.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# docker-auth-secrets.sh +# +# Configures Docker to use the workbench-secret credential helper for +# registries defined as dockerRegistry secrets in secrets.json. +# +# Runs after parse-devcontainer.sh (which creates secrets.json) and before +# devcontainer build. +# +# Usage: +# ./docker-auth-secrets.sh + +set -o errexit +set -o nounset +set -o pipefail +set -o xtrace + +readonly SECRETS_JSON="/home/core/secrets.json" + +if [[ ! -f "${SECRETS_JSON}" ]]; then + echo "No secrets.json found. Skipping secret docker auth setup." + exit 0 +fi + +DOCKER_REGISTRIES="$(jq -r '.[] | select(.dockerRegistry) | .dockerRegistry' "${SECRETS_JSON}")" +readonly DOCKER_REGISTRIES + +if [[ -z "${DOCKER_REGISTRIES}" ]]; then + echo "No dockerRegistry secrets found. Skipping." + exit 0 +fi + +DOCKER_CONFIG_DIR="${HOME:-/root}/.docker" +readonly DOCKER_CONFIG_DIR +DOCKER_CONFIG_FILE="${DOCKER_CONFIG_DIR}/config.json" +readonly DOCKER_CONFIG_FILE + +mkdir -p "${DOCKER_CONFIG_DIR}" + +if [[ ! -f "${DOCKER_CONFIG_FILE}" ]]; then + echo '{}' > "${DOCKER_CONFIG_FILE}" +fi + +echo "Configuring Docker credential helpers for secret registries..." + +while read -r registry_url; do + [[ -z "${registry_url}" ]] && continue + echo " Configuring: ${registry_url}" + jq --arg registry "${registry_url}" \ + '.credHelpers[$registry] = "workbench-secret"' \ + "${DOCKER_CONFIG_FILE}" > "${DOCKER_CONFIG_FILE}.tmp" && \ + mv "${DOCKER_CONFIG_FILE}.tmp" "${DOCKER_CONFIG_FILE}" +done <<< "${DOCKER_REGISTRIES}" + +echo "Docker secret credential helper configuration complete." diff --git a/startupscript/butane/docker-credential-secrets.sh b/startupscript/butane/docker-credential-secrets.sh new file mode 100755 index 00000000..34fa7333 --- /dev/null +++ b/startupscript/butane/docker-credential-secrets.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# docker-credential-secrets.sh +# +# Docker credential helper that retrieves credentials from WSM secrets. +# Called by Docker via the credential helper protocol when pulling/pushing +# to registries configured with "credHelpers": {"registry": "workbench-secret"}. +# +# Usage: +# docker-credential-secrets.sh +# +# The resource-path argument is provided by the cloud-specific wrapper +# (e.g. "resources/controlled/gcp/gce-instances"). +# +# Prerequisites: +# - /home/core/metadata-utils.sh, /home/core/service-utils.sh, /home/core/secret-utils.sh +# - /home/core/secrets.json (created by parse-devcontainer.sh) +# - Signing key registered (register-key.sh) + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +readonly RESOURCE_PATH="$1" +readonly COMMAND="$2" + +case "${COMMAND}" in + store|erase) + echo "Warning: ${COMMAND} is not supported by this credential helper" >&2 + cat > /dev/null + exit 0 + ;; + list) + echo "{}" + exit 0 + ;; + get) + ;; + *) + echo "Error: Invalid command '${COMMAND}'" >&2 + exit 1 + ;; +esac + +# --- get: retrieve credentials for the requested registry --- + +read -r SERVER_URL || true +readonly SERVER_URL +REGISTRY_HOSTNAME="$(echo "${SERVER_URL}" | sed -E 's|^https?://([^/]+).*|\1|')" +readonly REGISTRY_HOSTNAME + +readonly SECRETS_JSON="/home/core/secrets.json" +if [[ ! -f "${SECRETS_JSON}" ]]; then + echo "Error: ${SECRETS_JSON} not found" >&2 + exit 1 +fi + +SECRET_ENTRY="$(jq --arg registry "${REGISTRY_HOSTNAME}" \ + '.[] | select(.dockerRegistry == $registry)' \ + "${SECRETS_JSON}")" +readonly SECRET_ENTRY + +if [[ -z "${SECRET_ENTRY}" || "${SECRET_ENTRY}" == "null" ]]; then + echo "Error: No secret configured for registry ${REGISTRY_HOSTNAME}" >&2 + exit 1 +fi + +SECRET_NAME="$(echo "${SECRET_ENTRY}" | jq -r '.name')" +readonly SECRET_NAME + +# shellcheck source=/dev/null +source /home/core/metadata-utils.sh +# shellcheck source=/dev/null +source /home/core/service-utils.sh +# shellcheck source=/dev/null +source /home/core/secret-utils.sh + +CLI_SERVER="$(get_metadata_value "terra-cli-server" "prod")" +readonly CLI_SERVER + +WSM_URL="$(get_service_url "wsm" "${CLI_SERVER}")" +readonly WSM_URL + +WORKSPACE_UFID="$(get_metadata_value "terra-workspace-id" "")" +readonly WORKSPACE_UFID +if [[ -z "${WORKSPACE_UFID}" ]]; then + echo "Error: No workspace ID found in metadata" >&2 + exit 1 +fi + +RESOURCE_ID="$(get_metadata_value "wb-resource-id" "")" +readonly RESOURCE_ID +if [[ -z "${RESOURCE_ID}" ]]; then + echo "Error: No resource ID found in metadata" >&2 + exit 1 +fi + +TOKEN="$(/home/core/wb.sh auth print-access-token)" +# shellcheck disable=SC2034 +readonly TOKEN + +WORKSPACE_ID="$(curl_with_auth TOKEN -s -f \ + "${WSM_URL}/api/workspaces/v1/workspaceByUserFacingId/${WORKSPACE_UFID}" \ + | jq -r '.id')" +readonly WORKSPACE_ID +if [[ -z "${WORKSPACE_ID}" || "${WORKSPACE_ID}" == "null" ]]; then + echo "Error: Failed to resolve workspace UUID for '${WORKSPACE_UFID}'" >&2 + exit 1 +fi + +KEY_FILE="/home/core/signing-key/signing.key" +readonly KEY_FILE +if [[ ! -f "${KEY_FILE}" ]]; then + echo "Error: Signing key not found at ${KEY_FILE}" >&2 + exit 1 +fi + +APP_RESOURCE="$(curl_with_auth TOKEN -s -f \ + "${WSM_URL}/api/workspaces/v1/${WORKSPACE_ID}/${RESOURCE_PATH}/${RESOURCE_ID}")" +readonly APP_RESOURCE + +SECRET_WORKSPACE_ID="$(echo "${APP_RESOURCE}" | jq -r --arg name "${SECRET_NAME}" '.attributes.secrets[$name].workspaceId')" +readonly SECRET_WORKSPACE_ID +SECRET_RESOURCE_ID="$(echo "${APP_RESOURCE}" | jq -r --arg name "${SECRET_NAME}" '.attributes.secrets[$name].resourceId')" +readonly SECRET_RESOURCE_ID + +if [[ -z "${SECRET_WORKSPACE_ID}" || "${SECRET_WORKSPACE_ID}" == "null" || \ + -z "${SECRET_RESOURCE_ID}" || "${SECRET_RESOURCE_ID}" == "null" ]]; then + echo "Error: Secret '${SECRET_NAME}' not found in app resource's attached secrets" >&2 + exit 1 +fi + +validate_allowed_secret "${SECRET_ENTRY}" "${SECRET_WORKSPACE_ID}" "${SECRET_RESOURCE_ID}" + +CREDENTIAL="$(retrieve_secret TOKEN "${WSM_URL}" "${RESOURCE_ID}" "${KEY_FILE}" \ + "${SECRET_WORKSPACE_ID}" "${SECRET_RESOURCE_ID}")" +readonly CREDENTIAL + +if ! echo "${CREDENTIAL}" | jq -e '.Username and .Secret' >/dev/null 2>&1; then + echo "Error: Secret '${SECRET_NAME}' is not valid docker credential JSON (expected Username and Secret fields)" >&2 + exit 1 +fi + +echo "${CREDENTIAL}" | jq --arg url "${SERVER_URL}" '. + {"ServerURL": $url}' diff --git a/startupscript/butane/gcp/docker-credential-workbench-secret b/startupscript/butane/gcp/docker-credential-workbench-secret new file mode 100755 index 00000000..95e59e83 --- /dev/null +++ b/startupscript/butane/gcp/docker-credential-workbench-secret @@ -0,0 +1,2 @@ +#!/bin/bash +/home/core/docker-credential-secrets.sh "resources/controlled/gcp/gce-instances" "$@" diff --git a/startupscript/butane/secret-utils.sh b/startupscript/butane/secret-utils.sh new file mode 100644 index 00000000..0f9644a5 --- /dev/null +++ b/startupscript/butane/secret-utils.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +# secret-utils.sh +# +# Shared functions for scripts that interact with WSM secrets. +# This script is sourced, not executed directly. It expects service-utils.sh +# to already be sourced (for curl_with_auth). + +# Signs a nonce with the Ed25519 private key. +# Args: +# $1 - nonce string +# $2 - path to the Ed25519 private signing key +# Outputs: base64-encoded signature on stdout +function sign_nonce() { + local nonce="$1" + local key_file="$2" + + local nonce_file + nonce_file="$(mktemp)" + trap 'rm -f "${nonce_file}"' RETURN + + echo -n "${nonce}" > "${nonce_file}" + openssl pkeyutl -sign -inkey "${key_file}" -rawin -in "${nonce_file}" \ + | base64 | tr -d '\n' +} +readonly -f sign_nonce + +# Retrieves a secret value via the WSM challenge/sign/read protocol. +# Args: +# $1 - name of the variable holding the auth token (passed to curl_with_auth) +# $2 - WSM base URL +# $3 - app resource ID (the challenge requestor) +# $4 - path to the Ed25519 private signing key +# $5 - workspace ID containing the secret +# $6 - secret resource ID +# Outputs: decrypted secret value on stdout +function retrieve_secret() { + local token_var="$1" + local wsm_url="$2" + local app_resource_id="$3" + local key_file="$4" + local secret_workspace_id="$5" + local secret_resource_id="$6" + + local challenge_request + challenge_request="$(jq -n --arg appResourceId "${app_resource_id}" \ + '{"identifier": {"appResourceId": $appResourceId}}')" + + local nonce + nonce="$(curl_with_auth "${token_var}" -s -f -X POST \ + -H "Content-Type: application/json" \ + "${wsm_url}/api/workspaces/v1/${secret_workspace_id}/secrets/${secret_resource_id}/challenge" \ + -d "${challenge_request}" | jq -r '.nonce')" + + if [[ -z "${nonce}" || "${nonce}" == "null" ]]; then + >&2 echo "ERROR: Failed to get challenge nonce for secret ${secret_resource_id}." + return 1 + fi + + local signature + signature="$(sign_nonce "${nonce}" "${key_file}")" + + local read_request + read_request="$(jq -n \ + --arg appResourceId "${app_resource_id}" \ + --arg nonce "${nonce}" \ + --arg signature "${signature}" \ + '{ + "identifier": {"appResourceId": $appResourceId}, + "nonce": $nonce, + "signature": $signature + }')" + + if [[ $- == *x* ]]; then + { set +o xtrace; } 2>/dev/null + trap 'set -o xtrace' RETURN + fi + + local secret_value_b64 + secret_value_b64="$(curl_with_auth "${token_var}" -s -f -X POST \ + -H "Content-Type: application/json" \ + "${wsm_url}/api/workspaces/v1/${secret_workspace_id}/secrets/${secret_resource_id}/read" \ + -d "${read_request}" | jq -r '.base64EncodedSecretValue')" + + if [[ -z "${secret_value_b64}" || "${secret_value_b64}" == "null" ]]; then + >&2 echo "ERROR: Failed to read secret ${secret_resource_id}." + return 1 + fi + + echo -n "${secret_value_b64}" | base64 -d +} +readonly -f retrieve_secret + +# Validates that an attached secret is permitted by the entry's allowedSecrets list. +# No-op if allowedSecrets is not specified in the entry. +# Args: +# $1 - secret entry JSON (from secrets.json) +# $2 - workspace ID of the attached secret +# $3 - resource ID of the attached secret +# Returns: 0 if allowed (or no restriction), 1 if not allowed +function validate_allowed_secret() { + local secret_entry="$1" + local workspace_id="$2" + local resource_id="$3" + + local allowed_secrets + allowed_secrets="$(echo "${secret_entry}" | jq '.allowedSecrets // empty')" + + if [[ -z "${allowed_secrets}" ]]; then + return 0 + fi + + local match + match="$(echo "${allowed_secrets}" | jq -e \ + --arg wid "${workspace_id}" \ + --arg rid "${resource_id}" \ + '[.[] | select(.workspaceId == $wid and .resourceId == $rid)] | length > 0')" + + if [[ "${match}" != "true" ]]; then + local secret_name + secret_name="$(echo "${secret_entry}" | jq -r '.name')" + >&2 echo "ERROR: Attached secret '${secret_name}' (workspace=${workspace_id}, resource=${resource_id}) is not in allowedSecrets." + return 1 + fi +} +readonly -f validate_allowed_secret diff --git a/tests/common/common.bash b/tests/common/common.bash index 05ebb65e..443f7839 100644 --- a/tests/common/common.bash +++ b/tests/common/common.bash @@ -2,7 +2,13 @@ CONTAINER_NAME="application-server" +exec_in_container() { + local user="$1" + shift + docker exec --user "$user" "$CONTAINER_NAME" "$@" +} + run_in_container() { - docker exec --user root "$CONTAINER_NAME" sudo -u "${TEST_USER}" bash -l -c \ + exec_in_container "${TEST_USER}" bash -l -c \ "set -o pipefail && set -o errexit && set -o nounset && $*" } diff --git a/tests/test-app-secrets.bats b/tests/test-app-secrets.bats new file mode 100644 index 00000000..469b28d0 --- /dev/null +++ b/tests/test-app-secrets.bats @@ -0,0 +1,39 @@ +setup_file() { + echo "# Running ${BATS_TEST_FILENAME##*/}" >&3 +} + +setup() { + load common/common +} + +get_pid1_env() { + exec_in_container root \ + sh -c "tr '\0' '\n' < /proc/1/environ | grep \"^${1}=\" | sed \"s/^${1}=//\"" +} + +@test "secret pipe is removed after injection" { + ! exec_in_container root test -e /tmp/secrets +} + +@test "secret: EXAMPLE_SECRET has correct value" { + result="$(get_pid1_env EXAMPLE_SECRET)" + [ "$result" = "test-value-secret" ] +} + +@test "secret: PIPE_SECRET fd can only be read once" { + fd_path="$(get_pid1_env PIPE_SECRET)" + fd="${fd_path#/dev/fd/}" + result="$(exec_in_container root cat "/proc/1/fd/${fd}")" + [ "$result" = "test-pipe-secret" ] + result="$(exec_in_container root cat "/proc/1/fd/${fd}")" + [ "$result" = "" ] +} + +@test "secret: PATH_SECRET fd is readable multiple times" { + fd_path="$(get_pid1_env PATH_SECRET)" + fd="${fd_path#/dev/fd/}" + result="$(exec_in_container root cat "/proc/1/fd/${fd}")" + [ "$result" = "test-path-secret" ] + result="$(exec_in_container root cat "/proc/1/fd/${fd}")" + [ "$result" = "test-path-secret" ] +} diff --git a/tests/test-app-secrets.sh b/tests/test-app-secrets.sh new file mode 100755 index 00000000..a705048a --- /dev/null +++ b/tests/test-app-secrets.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -o errexit +export TEST_USER="root" + +CONTAINER_NAME="application-server" + +# Verify pipe exists with correct permissions +echo "Checking secret pipe..." +docker exec --user root "$CONTAINER_NAME" test -p /tmp/secrets +result="$(docker exec --user root "$CONTAINER_NAME" stat -c '%a' /tmp/secrets)" +if [ "$result" != "600" ]; then + echo "ERROR: Expected pipe permissions 600, got ${result}" + exit 1 +fi + +# Inject mock secrets to unblock the secret receiver +echo "Injecting mock secrets..." +echo '[ + {"type":"valueVar","value":"test-value-secret","target":"EXAMPLE_SECRET"}, + {"type":"pipeVar","value":"test-pipe-secret","target":"PIPE_SECRET"}, + {"type":"pathVar","value":"test-path-secret","target":"PATH_SECRET"}, + {"type":"valueVar","value":"test-multi-secret","target":"MULTI_VALUE"}, + {"type":"pathVar","value":"test-multi-secret","target":"MULTI_PATH"} +]' | timeout 30 docker exec --user root -i "$CONTAINER_NAME" sh -c 'cat > /tmp/secrets' + +bats tests/test-app-secrets.bats