From 35c5be432dce03ad6738664c4fc66baca2fc01e6 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Thu, 10 Sep 2026 21:39:39 +0100 Subject: [PATCH] feat(sbom): read build secrets in packages env values Passing a mounted secret to a package manager required shell in the value, which no longer runs. A `packages[].env` value can now reference a declared secret with `%secret:%` for its contents or `%secret_path:%` for its mount path, following the `%image%` family of werf placeholders; any other `%...%` sequence stays literal. References are resolved while the package manager runs, so the secret stays out of the build instructions, the stage digest and the image. An undeclared or malformed secret id fails during configuration parsing. Signed-off-by: Aleksei Igrychev --- .../usage/build/stapel/instructions.md | 28 ++++- .../usage/build/stapel/instructions.md | 28 ++++- pkg/config/packages_commands.go | 3 +- pkg/config/packages_env.go | 105 ++++++++++++++++++ pkg/config/packages_env_test.go | 77 +++++++++++++ pkg/config/raw_packages_directive_test.go | 25 +++++ pkg/config/raw_stapel_image.go | 4 + pkg/config/stubs_test.go | 12 ++ 8 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 pkg/config/packages_env.go create mode 100644 pkg/config/packages_env_test.go diff --git a/docs/pages_en/usage/build/stapel/instructions.md b/docs/pages_en/usage/build/stapel/instructions.md index 6cdc751d17..2c6664d63b 100644 --- a/docs/pages_en/usage/build/stapel/instructions.md +++ b/docs/pages_en/usage/build/stapel/instructions.md @@ -268,7 +268,7 @@ packages: Runs `pnpm install --frozen-lockfile`. Default files: `package.json` (spec) and `pnpm-lock.yaml` (lock). -All file-based types support `workdir` (required), `spec` (optional, overrides default manifest filename), and `lock` (optional, overrides default lock filename). All types, including `os-pm`, support an optional `env: {KEY: value}` field — the environment variables are added to the install command. Values are passed to the package manager as is: shell constructs such as `$(...)`, backticks and `$VARIABLE` are not evaluated. Multiple entries of the same or different types can be combined in one image: +All file-based types support `workdir` (required), `spec` (optional, overrides default manifest filename), and `lock` (optional, overrides default lock filename). All types, including `os-pm`, support an optional `env: {KEY: value}` field — the environment variables are added to the install command. Values are passed to the package manager as is: shell constructs such as `$(...)`, backticks and `$VARIABLE` are not evaluated. A value may reference a declared build secret, see [Secrets in packages](#secrets-in-packages). Multiple entries of the same or different types can be combined in one image: ```yaml packages: @@ -284,6 +284,32 @@ packages: - libssl-dev ``` +### Secrets in packages + +A `packages[].env` value can reference a secret declared in the `secrets` section: + +- `%secret:%` — the contents of the secret, without trailing newlines; +- `%secret_path:%` — the path the secret is mounted at, `/run/secrets/`. + +A reference is resolved while the package manager runs, so the secret is never part of the build instructions, the stage digest or the resulting image. Referencing a secret that is not declared fails the build during configuration parsing. Any other `%...%` sequence stays literal. + +```yaml +secrets: + - id: GOPROXY + env: GOPROXY + - id: CI_JOB_TOKEN + env: CI_JOB_TOKEN +packages: + - type: go-mod + workdir: /app + env: + GOPROXY: "%secret:GOPROXY%" + GOPRIVATE: git.example.com/* + GIT_CONFIG_KEY_0: 'url.https://gitlab-ci-token:%secret:CI_JOB_TOKEN%@git.example.com/.insteadOf' + GIT_CONFIG_VALUE_0: 'https://git.example.com/' + GIT_CONFIG_COUNT: "1" +``` + For file-based types, declare the spec and lock files in `git.stageDependencies.packages` — otherwise changes to their contents will not rebuild the packages stage, leaving installed dependencies stale while the SBOM reports the updated files: ```yaml diff --git a/docs/pages_ru/usage/build/stapel/instructions.md b/docs/pages_ru/usage/build/stapel/instructions.md index e88c4978f8..00da2060e7 100644 --- a/docs/pages_ru/usage/build/stapel/instructions.md +++ b/docs/pages_ru/usage/build/stapel/instructions.md @@ -281,7 +281,7 @@ packages: Выполняет `luarocks install --only-deps `. В отличие от остальных экосистем, у `lua-rock` нет значения `spec` по умолчанию: поле `spec` обязательно и должно указывать на файл `.rockspec` (имена rockspec-файлов следуют соглашению `<имя>-<версия>-<ревизия>.rockspec`). У LuaRocks нет lock-файла, поэтому поле `lock` отклоняется с ошибкой. Для генерации SBOM syft использует каталогизатор `lua-rock-cataloger`. -Все файловые типы поддерживают поля `workdir` (обязательно), `spec` (опционально, переопределяет имя файла манифеста) и `lock` (опционально, переопределяет имя lock-файла). Все типы, включая `os-pm`, поддерживают опциональное поле `env: {KEY: value}` — переменные окружения добавляются к команде установки. Значения передаются пакетному менеджеру как есть: конструкции шелла вида `$(...)`, обратные кавычки и `$VARIABLE` не вычисляются. В одном образе можно комбинировать несколько записей одного или разных типов: +Все файловые типы поддерживают поля `workdir` (обязательно), `spec` (опционально, переопределяет имя файла манифеста) и `lock` (опционально, переопределяет имя lock-файла). Все типы, включая `os-pm`, поддерживают опциональное поле `env: {KEY: value}` — переменные окружения добавляются к команде установки. Значения передаются пакетному менеджеру как есть: конструкции шелла вида `$(...)`, обратные кавычки и `$VARIABLE` не вычисляются. Значение может ссылаться на объявленный секрет сборки, см. [Секреты в packages](#секреты-в-packages). В одном образе можно комбинировать несколько записей одного или разных типов: ```yaml packages: @@ -297,6 +297,32 @@ packages: - libssl-dev ``` +### Секреты в packages + +Значение `packages[].env` может ссылаться на секрет, объявленный в секции `secrets`: + +- `%secret:%` — содержимое секрета без завершающих переводов строки; +- `%secret_path:%` — путь, по которому секрет смонтирован, `/run/secrets/`. + +Ссылка разрешается во время работы пакетного менеджера, поэтому секрет не попадает ни в инструкции сборки, ни в дайджест стадии, ни в итоговый образ. Ссылка на необъявленный секрет прерывает сборку на этапе разбора конфигурации. Любая другая последовательность вида `%...%` остаётся литералом. + +```yaml +secrets: + - id: GOPROXY + env: GOPROXY + - id: CI_JOB_TOKEN + env: CI_JOB_TOKEN +packages: + - type: go-mod + workdir: /app + env: + GOPROXY: "%secret:GOPROXY%" + GOPRIVATE: git.example.com/* + GIT_CONFIG_KEY_0: 'url.https://gitlab-ci-token:%secret:CI_JOB_TOKEN%@git.example.com/.insteadOf' + GIT_CONFIG_VALUE_0: 'https://git.example.com/' + GIT_CONFIG_COUNT: "1" +``` + Для файловых типов укажите spec- и lock-файлы в `git.stageDependencies.packages` — иначе изменение их содержимого не приведет к пересборке стадии packages: установленные зависимости устареют, а SBOM при этом будет отражать обновленные файлы: ```yaml diff --git a/pkg/config/packages_commands.go b/pkg/config/packages_commands.go index 789e321f6b..142daee072 100644 --- a/pkg/config/packages_commands.go +++ b/pkg/config/packages_commands.go @@ -6,7 +6,6 @@ import ( "sort" "strings" - "github.com/alessio/shellescape" "github.com/samber/lo" "github.com/werf/werf/v2/pkg/sbom/os_pm/metadata" @@ -22,7 +21,7 @@ func formatEnvVars(env map[string]string) string { sort.Strings(keys) parts := lo.Map(keys, func(k string, _ int) string { - return fmt.Sprintf("%s=%s", k, shellescape.Quote(env[k])) + return fmt.Sprintf("%s=%s", k, formatPackageEnvValue(env[k])) }) return strings.Join(parts, " ") } diff --git a/pkg/config/packages_env.go b/pkg/config/packages_env.go new file mode 100644 index 0000000000..ae7fb2f151 --- /dev/null +++ b/pkg/config/packages_env.go @@ -0,0 +1,105 @@ +package config + +import ( + "fmt" + "regexp" + "strings" + + "github.com/alessio/shellescape" +) + +const ( + packageSecretsDir = "/run/secrets/" + + packageEnvNamespaceSecret = "secret" + packageEnvNamespaceSecretPath = "secret_path" +) + +var ( + packageEnvReferenceRe = regexp.MustCompile(`%(secret_path|secret):([^%]*)%`) + packageSecretIDRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) +) + +type packageEnvPart struct { + literal string + namespace string + secretID string +} + +func splitPackageEnvValue(value string) ([]packageEnvPart, error) { + var parts []packageEnvPart + + end := 0 + for _, match := range packageEnvReferenceRe.FindAllStringSubmatchIndex(value, -1) { + namespace, secretID := value[match[2]:match[3]], value[match[4]:match[5]] + if !packageSecretIDRe.MatchString(secretID) { + return nil, fmt.Errorf("invalid secret id %q in %q: must match %s", secretID, value, packageSecretIDRe) + } + + if match[0] > end { + parts = append(parts, packageEnvPart{literal: value[end:match[0]]}) + } + parts = append(parts, packageEnvPart{namespace: namespace, secretID: secretID}) + end = match[1] + } + + if end < len(value) { + parts = append(parts, packageEnvPart{literal: value[end:]}) + } + + return parts, nil +} + +func formatPackageEnvValue(value string) string { + parts, err := splitPackageEnvValue(value) + if err != nil { + // Malformed references are rejected while parsing the config, so reaching this point + // means the value never went through validatePackageEnvValues: keep it literal. + return shellescape.Quote(value) + } + + if len(parts) == 0 { + return shellescape.Quote("") + } + + formatted := make([]string, 0, len(parts)) + for _, part := range parts { + switch part.namespace { + case packageEnvNamespaceSecret: + formatted = append(formatted, fmt.Sprintf(`"$(<%s%s)"`, packageSecretsDir, part.secretID)) + case packageEnvNamespaceSecretPath: + formatted = append(formatted, shellescape.Quote(packageSecretsDir+part.secretID)) + default: + formatted = append(formatted, shellescape.Quote(part.literal)) + } + } + + return strings.Join(formatted, "") +} + +func validatePackageEnvValues(rawPackages []*rawPackagesDirective, secrets []Secret) error { + declaredSecretIDs := make(map[string]struct{}, len(secrets)) + for _, secret := range secrets { + declaredSecretIDs[secret.Id] = struct{}{} + } + + for index, rawPackage := range rawPackages { + for name, value := range rawPackage.Env { + parts, err := splitPackageEnvValue(value) + if err != nil { + return newDetailedConfigError(fmt.Sprintf("packages[%d].env[%q]: %s", index, name, err), rawPackage, rawPackage.docForErrors()) + } + + for _, part := range parts { + if part.namespace == "" { + continue + } + if _, declared := declaredSecretIDs[part.secretID]; !declared { + return newDetailedConfigError(fmt.Sprintf("packages[%d].env[%q] references secret %q, which is not declared in the `secrets` section", index, name, part.secretID), rawPackage, rawPackage.docForErrors()) + } + } + } + } + + return nil +} diff --git a/pkg/config/packages_env_test.go b/pkg/config/packages_env_test.go new file mode 100644 index 0000000000..a36c96856e --- /dev/null +++ b/pkg/config/packages_env_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("formatPackageEnvValue", func() { + DescribeTable("keeps the secret out of the generated command", + func(value, expected string) { + Expect(formatPackageEnvValue(value)).To(Equal(expected)) + }, + Entry("secret content", "%secret:TOKEN%", `"$(