From 9e525b284d359f600ac8163134a3befa781b2f73 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Fri, 23 Oct 2026 13:53:11 +0300 Subject: [PATCH 1/3] feat(build, stapel): sign files matched by a glob pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sign[].path` used to accept one absolute file path, so a set of files known only after the build — the kernel modules `make modules_install` produces, several hundred of them — could not be signed without listing each one in werf.yaml. Accept an absolute glob pattern (`*`, `?`, `[...]`, `{a,b}`, `**`) and expand it against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`, symlinks are neither matched nor followed, and a pattern matching nothing fails the build the way a missing literal path does. Expansion goes through the ImageReader of the container backend, which gains a Glob method: Docker streams the pattern's literal base directory out once with `docker cp`, Buildah walks it inside the mounted container root with the base resolved against that root so a symlink there cannot lead to a host path. The Buildah backend now also opens a real reader — one container created and mounted for all the files of a stage — instead of a container per file. Duplicate-file and signature-of-the-same-stage conflicts, which the config parser checks statically for literal paths, are checked for patterns at expansion time against the actual file set. Under the legacy stapel builder the install commands go through a script in the mounted signatures directory, as the command line has a fixed size limit a few hundred files would exceed. Signed-off-by: Radmir Khurum --- .agents/skills/werf-to-delivery-kit/SKILL.md | 4 +- docs/_data/werf_yaml.yml | 4 +- docs/pages_en/usage/build/stapel/imports.md | 10 +- docs/pages_ru/usage/build/stapel/imports.md | 10 +- pkg/build/stage/sign_files.go | 89 +++++++++-- pkg/build/stage/sign_files_test.go | 139 +++++++++++++++++- pkg/config/raw_stapel_image.go | 12 +- pkg/config/raw_stapel_image_test.go | 36 +++++ pkg/config/sign.go | 27 +++- pkg/container_backend/buildah_image_reader.go | 125 +++++++++++++--- .../buildah_image_reader_test.go | 30 ++++ pkg/container_backend/docker_image_reader.go | 63 ++++++++ .../docker_image_reader_test.go | 46 +++++- pkg/container_backend/interface.go | 4 + schemas/werf.json | 2 +- .../_fixtures/sign_files/state_glob/werf.yaml | 23 +++ test/e2e/build/sign_files_test.go | 51 +++++++ test/mock/container_backend.go | 15 ++ 18 files changed, 627 insertions(+), 63 deletions(-) create mode 100644 test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml diff --git a/.agents/skills/werf-to-delivery-kit/SKILL.md b/.agents/skills/werf-to-delivery-kit/SKILL.md index 18e0aa3dd1..709e6274ff 100644 --- a/.agents/skills/werf-to-delivery-kit/SKILL.md +++ b/.agents/skills/werf-to-delivery-kit/SKILL.md @@ -332,7 +332,7 @@ import: - Формат подписи фиксирован: отсоединённый PKCS#7 (CMS) `SignedData`, SHA-256, RSASSA-PKCS1-v1_5, без подписываемых атрибутов, со встроенным leaf-сертификатом (intermediates не встраиваются) — как у `systemd-keyutil pkcs7` / `openssl smime -sign -binary -noattr -outform der`. Ключ только RSA. Если старая сборка делала то же самое — переписывать на `sign:`; иначе (в т.ч. потребителю нужна встроенная цепочка) — стоп-условие. - Все `sign:` одного запуска delivery-kit используют **одну** пару ключ/сертификат. Сверить, каким ключом подписывается каждый файл; если в конфиге сейчас разные ключи для разных файлов — остановиться и согласовать с пользователем, не объединять ключи и не менять CI. -- `path` — абсолютный путь к обычному файлу (не каталог), каждый `path` один раз. Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. +- `path` — абсолютный путь к обычному файлу (не каталог) либо абсолютный glob-шаблон (`*`, `?`, `[...]`, `{a,b}`, `**`; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: каждый подошедший обычный файл получает свой `.p7s`, симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. - Проверку подписи, которая раньше шла сразу после подписания, не удалять — перенести: стадия, которая производит файл, своей подписи не видит. Файл из `install` → `after: install`, проверка в `beforeSetup`/`setup`; файл из `beforeSetup` → `before: setup`, проверка в `setup`; файл из `setup` (последняя shell-стадия) → подпись без `before`/`after`, проверка в отдельном образе, который импортирует файл и `.p7s`. Если сохранить проверку так не получается — стоп-условие. Если проверке нужен `openssl` — пакет в `spec:` os-pm. - Зависимые образы, которые импортировали подписанный файл, импортируют и `.p7s`. - Удалить вход в Vault и вызовы подписи из shell. `secrets:` с `VAULT_*`/JWT, `mount:` с клиентом Vault (`d8`, `vault`), запрос сертификата из shell и соответствующие `secrets.allowEnvVariables`/`mount.allowFromPaths` в `werf-giterminism.yaml` удалять только после проверки всех их использований: если они нужны другому образу или проверке подписи (например, CA-сертификат) — оставить. @@ -427,4 +427,4 @@ head -1 <путь-к-каталогу> - Нет нужного OS-пакета/образа в файле базовых образов → **прекратить** переписывание, сообщить пользователю: запросить пакет у команды container-base. Для менеджеров языковых экосистем (yarn, pnpm, uv, poetry) сначала применить `manager:` (§3). - Не удаётся заменить сетевую shell-команду ни одной директивой (`git:`/`packages:`/`vex:`/`sign:`) → не оставлять её в shell "как есть" и не изобретать обходы, а зафиксировать проблему и спросить пользователя (типовые случаи — fuzz и svace, см. §4). - Пакет из pm не работает (`symbol lookup error`, нет манифеста в registry) → это дефект container-base, не конфига: сообщить пользователю, не тащить бинарь/библиотеку из другого базового образа через `import`. -- Подпись в shell не сводится к отсоединённому PKCS#7 RSA/SHA-256 рядом с файлом (подпись встраивается в сам файл — Authenticode/`pesign`, `sbsign`, подпись модулей ядра; голая подпись без CMS; не-RSA ключ; подписываемые атрибуты) → `sign:` её не заменяет: не переписывать этот образ и не изобретать обходы, зафиксировать и сообщить пользователю. +- Подпись в shell не сводится к отсоединённому PKCS#7 RSA/SHA-256 рядом с файлом (подпись встраивается в сам файл — Authenticode/`pesign`, `sbsign`; голая подпись без CMS; не-RSA ключ; подписываемые атрибуты) → `sign:` её не заменяет: не переписывать этот образ и не изобретать обходы, зафиксировать и сообщить пользователю. Подпись модулей ядра — исключение: `scripts/sign-file -s sha256 ` дописывает к модулю именно отсоединённый PKCS#7 (`-s` берёт готовую подпись, `` при этом не читается), поэтому она сводится к `sign:` с glob (`after: install`) и `sign-file -s` + `rm *.p7s` в `beforeSetup`. diff --git a/docs/_data/werf_yaml.yml b/docs/_data/werf_yaml.yml index d9243cad7e..5206ce746c 100644 --- a/docs/_data/werf_yaml.yml +++ b/docs/_data/werf_yaml.yml @@ -941,8 +941,8 @@ sections: - name: path value: "string" description: - en: "The absolute path of the file to sign; the detached PKCS#7 signature is written next to it as `.p7s`" - ru: "Абсолютный путь к подписываемому файлу; отсоединённая подпись PKCS#7 записывается рядом как `.p7s`" + en: "The absolute path of the file to sign, or an absolute glob pattern matching the files to sign; the detached PKCS#7 signature of each file is written next to it as `.p7s`" + ru: "Абсолютный путь к подписываемому файлу либо абсолютный glob-шаблон подписываемых файлов; отсоединённая подпись PKCS#7 каждого файла записывается рядом как `.p7s`" - name: before value: "string" description: diff --git a/docs/pages_en/usage/build/stapel/imports.md b/docs/pages_en/usage/build/stapel/imports.md index 4d1ae8304b..ae74b8c8ad 100644 --- a/docs/pages_en/usage/build/stapel/imports.md +++ b/docs/pages_en/usage/build/stapel/imports.md @@ -113,7 +113,15 @@ sign: after: install ``` -Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. To sign a generated signature, put the second entry at a later signing position. +`path` may also be a glob pattern (`*`, `?`, `[...]`, `{a,b}`, `**`), expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. Symlinks are neither matched nor followed, so a file is signed under its real path only, and a pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: + +```yaml +sign: +- path: /out/lib/modules/**/*.ko + after: install +``` + +Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. The same holds for the files a pattern expands to, and a file reached through two entries is an error as well; both are detected when the pattern is expanded. To sign a generated signature, put the second entry at a later signing position. werf reads the file out of the image built so far, signs it on the build host and adds `.p7s` as a new layer. The signature is a detached PKCS#7 (CMS) `SignedData` over the file content — SHA-256, RSASSA-PKCS1-v1_5, no authenticated attributes, the signing certificate embedded — the same shape `systemd-keyutil pkcs7` produces, so systemd and the Linux kernel accept it as a dm-verity root hash signature. The key therefore has to be RSA. diff --git a/docs/pages_ru/usage/build/stapel/imports.md b/docs/pages_ru/usage/build/stapel/imports.md index ff563a444e..d7ccab1102 100644 --- a/docs/pages_ru/usage/build/stapel/imports.md +++ b/docs/pages_ru/usage/build/stapel/imports.md @@ -113,7 +113,15 @@ sign: after: install ``` -Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. +`path` может быть и glob-шаблоном (`*`, `?`, `[...]`, `{a,b}`, `**`): он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. Символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём, а шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: + +```yaml +sign: +- path: /out/lib/modules/**/*.ko + after: install +``` + +Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. То же относится к файлам, в которые раскрывается шаблон; файл, достижимый через две записи, — тоже ошибка. И то и другое обнаруживается при раскрытии шаблона. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. werf читает файл из собранного к этому моменту образа, подписывает его на сборочном хосте и добавляет `.p7s` новым слоем. Подпись — отсоединённый PKCS#7 (CMS) `SignedData` над содержимым файла: SHA-256, RSASSA-PKCS1-v1_5, без аутентифицированных атрибутов, со встроенным сертификатом подписанта — ровно та форма, которую производит `systemd-keyutil pkcs7`, поэтому systemd и ядро Linux принимают её как подпись dm-verity root hash. Ключ, соответственно, должен быть RSA. diff --git a/pkg/build/stage/sign_files.go b/pkg/build/stage/sign_files.go index 2eb7da2356..81368046e6 100644 --- a/pkg/build/stage/sign_files.go +++ b/pkg/build/stage/sign_files.go @@ -132,59 +132,118 @@ func (s *SignFilesStage) PrepareImage(ctx context.Context, c Conveyor, cb contai return fmt.Errorf("create signatures dir %s: %w", signaturesDir, err) } + reader, err := cb.OpenImageReader(ctx, prevBuiltImage.Image.Name(), container_backend.ReadFileFromImageOpts{TargetPlatform: s.targetPlatform}) + if err != nil { + return fmt.Errorf("open image %q to sign files of image %q: %w", prevBuiltImage.Image.Name(), s.imageName, err) + } + defer func() { + if err := reader.Close(ctx); err != nil { + logboek.Context(ctx).Warn().LogF("WARNING: %s\n", err) + } + }() + + files, err := s.expandSigns(ctx, reader) + if err != nil { + return err + } + legacy := c.UseLegacyStapelBuilder(cb) - var commands []string - for i, sign := range s.signs { - content, err := cb.ReadFileFromImage(ctx, prevBuiltImage.Image.Name(), sign.Path, container_backend.ReadFileFromImageOpts{TargetPlatform: s.targetPlatform}) + var installCommands []string + for i, filePath := range files { + content, err := reader.ReadFile(ctx, filePath) if err != nil { - return fmt.Errorf("read %s to sign from image %q: %w", sign.Path, s.imageName, err) + return fmt.Errorf("read %s to sign from image %q: %w", filePath, s.imageName, err) } var signature []byte - if err := logboek.Context(ctx).Default().LogProcess("Signing %s", sign.Path).DoError(func() error { + if err := logboek.Context(ctx).Default().LogProcess("Signing %s", filePath).DoError(func() error { var signErr error signature, signErr = signing.SignDetachedPKCS7(ctx, content, s.options.Signer()) return signErr }); err != nil { - return fmt.Errorf("sign %s of image %q: %w", sign.Path, s.imageName, err) + return fmt.Errorf("sign %s of image %q: %w", filePath, s.imageName, err) } - outputName := path.Base(sign.OutputPath()) + outputPath := config.SignatureOutputPath(filePath) + outputName := path.Base(outputPath) if legacy { // Two signed files can share a base name: keep every signature in its own // directory, or the later one overwrites the earlier and both destinations // receive the same signature. entryDir := strconv.Itoa(i) if err := os.MkdirAll(filepath.Join(signaturesDir, entryDir), os.ModePerm); err != nil { - return fmt.Errorf("create signature dir for %s: %w", sign.Path, err) + return fmt.Errorf("create signature dir for %s: %w", filePath, err) } hostPath := filepath.Join(signaturesDir, entryDir, outputName) if err := os.WriteFile(hostPath, signature, 0o644); err != nil { return fmt.Errorf("write signature %s: %w", hostPath, err) } containerPath := path.Join(s.containerWerfDir, "sign", string(s.Name()), entryDir, outputName) - commands = append(commands, - fmt.Sprintf("%s -p %s", stapel.MkdirBinPath(), shellescape.Quote(path.Dir(sign.OutputPath()))), - fmt.Sprintf("%s -m 0644 %s %s", stapel.InstallBinPath(), shellescape.Quote(containerPath), shellescape.Quote(sign.OutputPath())), + installCommands = append(installCommands, + fmt.Sprintf("%s -p %s", stapel.MkdirBinPath(), shellescape.Quote(path.Dir(outputPath))), + fmt.Sprintf("%s -m 0644 %s %s", stapel.InstallBinPath(), shellescape.Quote(containerPath), shellescape.Quote(outputPath)), ) continue } archive, err := singleFileTar(outputName, signature) if err != nil { - return fmt.Errorf("pack signature of %s: %w", sign.Path, err) + return fmt.Errorf("pack signature of %s: %w", filePath, err) } - stageImage.Builder.StapelStageBuilder().AddDataArchive(io.NopCloser(archive), container_backend.FileArchive, sign.OutputPath(), container_backend.AddDataArchiveOptions{}) + stageImage.Builder.StapelStageBuilder().AddDataArchive(io.NopCloser(archive), container_backend.FileArchive, outputPath, container_backend.AddDataArchiveOptions{}) } if legacy { - stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddVolume(fmt.Sprintf("%s:%s:ro", signaturesDir, path.Join(s.containerWerfDir, "sign", string(s.Name())))) - stageImage.Builder.LegacyStapelStageBuilder().Container().AddServiceRunCommands(commands...) + // A pattern can expand to hundreds of files; a script keeps their install + // commands out of the container's command line, which has a fixed size limit. + const scriptName = "install.sh" + if err := stapel.CreateScript(filepath.Join(signaturesDir, scriptName), installCommands); err != nil { + return fmt.Errorf("create signatures install script: %w", err) + } + containerSignaturesDir := path.Join(s.containerWerfDir, "sign", string(s.Name())) + stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddVolume(fmt.Sprintf("%s:%s:ro", signaturesDir, containerSignaturesDir)) + stageImage.Builder.LegacyStapelStageBuilder().Container().AddServiceRunCommands(path.Join(containerSignaturesDir, scriptName)) } return nil } +// expandSigns resolves the stage's entries to the files to sign, in entry order. A +// pattern that matches nothing fails the build, like a single path that does not exist: +// a declared signature is a requirement of the image. The same file reached through two +// entries, or a file that is the signature of another file of this stage, is rejected +// here for patterns the way the config parser rejects it for literal paths. +func (s *SignFilesStage) expandSigns(ctx context.Context, reader container_backend.ImageReader) ([]string, error) { + var files []string + signByFile := map[string]*config.Sign{} + for _, sign := range s.signs { + matches := []string{sign.Path} + if sign.IsPattern() { + var err error + matches, err = reader.Glob(ctx, sign.Path) + if err != nil { + return nil, fmt.Errorf("expand `path: %s` of sign in image %q: %w", sign.Path, s.imageName, err) + } + if len(matches) == 0 { + return nil, fmt.Errorf("`path: %s` of sign in image %q matches no file", sign.Path, s.imageName) + } + } + for _, filePath := range matches { + if other, exist := signByFile[filePath]; exist { + return nil, fmt.Errorf("file %s of image %q is signed by both `path: %s` and `path: %s` of sign", filePath, s.imageName, other.Path, sign.Path) + } + signByFile[filePath] = sign + files = append(files, filePath) + } + } + for _, filePath := range files { + if other, exist := signByFile[config.SignatureOutputPath(filePath)]; exist { + return nil, fmt.Errorf("file %s of image %q matched by `path: %s` of sign is the signature of %s signed at the same stage", config.SignatureOutputPath(filePath), s.imageName, other.Path, filePath) + } + } + return files, nil +} + func singleFileTar(name string, content []byte) (*bytes.Buffer, error) { var buf bytes.Buffer tw := tar.NewWriter(&buf) diff --git a/pkg/build/stage/sign_files_test.go b/pkg/build/stage/sign_files_test.go index e33937c07c..2845584848 100644 --- a/pkg/build/stage/sign_files_test.go +++ b/pkg/build/stage/sign_files_test.go @@ -9,13 +9,16 @@ import ( "encoding/base64" "encoding/pem" "fmt" + "io/fs" "math/big" "os" "path" "path/filepath" + "sort" "strings" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/deckhouse/delivery-kit-sdk/pkg/signver" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -27,24 +30,62 @@ import ( "github.com/werf/werf/v3/pkg/config" "github.com/werf/werf/v3/pkg/container_backend" "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/slug" ) type signFilesContainerBackendStub struct { *ContainerBackendStub - contentByTargetPlatform map[string][]byte + filesByTargetPlatform map[string]map[string][]byte } var _ container_backend.ContainerBackend = (*signFilesContainerBackendStub)(nil) -func (backend *signFilesContainerBackendStub) ReadFileFromImage(_ context.Context, _, filePath string, opts container_backend.ReadFileFromImageOpts) ([]byte, error) { - content, found := backend.contentByTargetPlatform[opts.TargetPlatform] +func (backend *signFilesContainerBackendStub) OpenImageReader(_ context.Context, _ string, opts container_backend.ReadFileFromImageOpts) (container_backend.ImageReader, error) { + files, found := backend.filesByTargetPlatform[opts.TargetPlatform] if !found { - return nil, fmt.Errorf("read %s: no image built for platform %q", filePath, opts.TargetPlatform) + return nil, fmt.Errorf("no image built for platform %q", opts.TargetPlatform) + } + return &signFilesImageReaderStub{files: files}, nil +} + +type signFilesImageReaderStub struct { + files map[string][]byte +} + +var _ container_backend.ImageReader = (*signFilesImageReaderStub)(nil) + +func (r *signFilesImageReaderStub) ReadFile(_ context.Context, filePath string) ([]byte, error) { + content, found := r.files[filePath] + if !found { + return nil, fmt.Errorf("read %s: %w", filePath, fs.ErrNotExist) } return content, nil } +func (r *signFilesImageReaderStub) Glob(_ context.Context, pattern string) ([]string, error) { + var matches []string + for filePath := range r.files { + matched, err := doublestar.Match(pattern, filePath) + if err != nil { + return nil, err + } + if matched { + matches = append(matches, filePath) + } + } + sort.Strings(matches) + return matches, nil +} + +func (r *signFilesImageReaderStub) ReadDir(_ context.Context, _, _ string, _ container_backend.ReadDirOpts) error { + return fmt.Errorf("not implemented") +} + +func (r *signFilesImageReaderStub) Close(_ context.Context) error { + return nil +} + var _ = Describe("SignFilesStage", func() { newSigner := func(cn string) *signing.Signer { key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -134,8 +175,11 @@ var _ = Describe("SignFilesStage", func() { "linux/arm64": []byte("arm64 root hash"), } backend := &signFilesContainerBackendStub{ - ContainerBackendStub: NewContainerBackendStub(), - contentByTargetPlatform: contentByTargetPlatform, + ContainerBackendStub: NewContainerBackendStub(), + filesByTargetPlatform: map[string]map[string][]byte{ + "linux/amd64": {signedPath: contentByTargetPlatform["linux/amd64"]}, + "linux/arm64": {signedPath: contentByTargetPlatform["linux/arm64"]}, + }, } conveyor := NewConveyorStub(NewGiterminismManagerStub(NewLocalGitRepoStub("9d8059842b6fde712c58315ca0ab4713d90761c0"), NewGiterminismInspectorStub()), nil, nil) imageConfig := &config.StapelImageBase{Name: "sysext", Sign: []*config.Sign{{Path: signedPath}}} @@ -192,6 +236,89 @@ var _ = Describe("SignFilesStage", func() { "preparing another platform must not overwrite the signature the first platform still has to install") }) + Describe("glob patterns", func() { + const targetPlatform = "linux/amd64" + + prepare := func(ctx context.Context, files map[string][]byte, signs ...*config.Sign) (string, error) { + backend := &signFilesContainerBackendStub{ + ContainerBackendStub: NewContainerBackendStub(), + filesByTargetPlatform: map[string]map[string][]byte{targetPlatform: files}, + } + conveyor := NewConveyorStub(NewGiterminismManagerStub(NewLocalGitRepoStub("9d8059842b6fde712c58315ca0ab4713d90761c0"), NewGiterminismInspectorStub()), nil, nil) + imageConfig := &config.StapelImageBase{Name: "kernel", Sign: signs} + imageTmpDir := filepath.Join(GinkgoT().TempDir(), "image", imageConfig.Name) + stage := GenerateSignFilesAfterInstallStage(imageConfig, enabledOptions(newSigner("a")), &BaseStageOptions{ + TargetPlatform: targetPlatform, + ImageName: imageConfig.Name, + ImageTmpDir: imageTmpDir, + ContainerWerfDir: "/.werf", + }) + Expect(stage).NotTo(BeNil()) + + prevBuiltImage := container_backend.NewLegacyStageImage(nil, "previous", backend, targetPlatform) + prevBuiltImage.SetStageDesc(&image.StageDesc{Info: &image.Info{}}) + stageImage := NewStageImage(backend, prevBuiltImage.Name(), container_backend.NewLegacyStageImage(prevBuiltImage, "result", backend, targetPlatform)) + + if err := stage.PrepareImage(ctx, conveyor, backend, &StageImage{Image: prevBuiltImage}, stageImage, nil); err != nil { + return "", err + } + return filepath.Join(imageTmpDir, "sign", slug.Slug(targetPlatform), string(stage.Name())), nil + } + + verifies := func(signaturePath string, content []byte) error { + der, err := os.ReadFile(signaturePath) + if err != nil { + return err + } + p7, err := pkcs7.Parse(der) + if err != nil { + return err + } + p7.Content = content + return p7.Verify() + } + + It("signs every regular file the pattern matches, in sorted order, and nothing else", func(ctx SpecContext) { + files := map[string][]byte{ + "/out/lib/modules/6.12/kernel/net/b.ko": []byte("module b"), + "/out/lib/modules/6.12/kernel/a.ko": []byte("module a"), + "/out/lib/modules/6.12/modules.order": []byte("not a module"), + "/out/lib/modules/6.12/kernel/net/b.ko.d": []byte("not a module either"), + } + + signaturesDir, err := prepare(ctx, files, &config.Sign{Path: "/out/lib/modules/**/*.ko", After: "install"}) + Expect(err).NotTo(HaveOccurred()) + + script, err := os.ReadFile(filepath.Join(signaturesDir, "install.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(script)).To(ContainSubstring(" /out/lib/modules/6.12/kernel/a.ko.p7s\n")) + Expect(string(script)).To(ContainSubstring(" /out/lib/modules/6.12/kernel/net/b.ko.p7s\n")) + Expect(string(script)).NotTo(ContainSubstring("modules.order")) + Expect(string(script)).NotTo(ContainSubstring("b.ko.d")) + + Expect(verifies(filepath.Join(signaturesDir, "0", "a.ko.p7s"), files["/out/lib/modules/6.12/kernel/a.ko"])).To(Succeed()) + Expect(verifies(filepath.Join(signaturesDir, "1", "b.ko.p7s"), files["/out/lib/modules/6.12/kernel/net/b.ko"])).To(Succeed()) + }) + + It("fails when the pattern matches no file, since a declared signature is a requirement of the image", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/notes.txt": []byte("x")}, &config.Sign{Path: "/out/**/*.ko", After: "install"}) + Expect(err).To(MatchError(ContainSubstring("`path: /out/**/*.ko` of sign in image \"kernel\" matches no file"))) + }) + + It("rejects a file reached through two entries", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/a.ko": []byte("a")}, + &config.Sign{Path: "/out/*.ko", After: "install"}, + &config.Sign{Path: "/out/a.ko", After: "install"}, + ) + Expect(err).To(MatchError(ContainSubstring("file /out/a.ko of image \"kernel\" is signed by both `path: /out/*.ko` and `path: /out/a.ko` of sign"))) + }) + + It("rejects a matched file that is the signature of another file signed at the same stage", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/a": []byte("a"), "/out/a.p7s": []byte("old signature")}, &config.Sign{Path: "/out/*", After: "install"}) + Expect(err).To(MatchError(ContainSubstring("file /out/a.p7s of image \"kernel\" matched by `path: /out/*` of sign is the signature of /out/a signed at the same stage"))) + }) + }) + It("is generated only for the position the directive names and only when enabled", func() { imageConfig := &config.StapelImageBase{Sign: []*config.Sign{{Path: "/out/x", Before: "install"}}} options := enabledOptions(newSigner("a")) diff --git a/pkg/config/raw_stapel_image.go b/pkg/config/raw_stapel_image.go index 3eabe098c7..c4c6a2a595 100644 --- a/pkg/config/raw_stapel_image.go +++ b/pkg/config/raw_stapel_image.go @@ -206,18 +206,22 @@ func (c *rawStapelImage) toStapelImageBaseDirective(ctx context.Context, gitermi if _, exist := signByPath[signDirective.Path]; exist { return nil, newDetailedConfigError(fmt.Sprintf("duplicated `path: %s` for sign!", signDirective.Path), rawSign, c.doc) } + signByPath[signDirective.Path] = signDirective + imageBase.Sign = append(imageBase.Sign, signDirective) + // The files of a pattern are known only at signing time, where the same + // conflicts are checked against the expanded set. + if signDirective.IsPattern() { + continue + } // All entries read the previous image, so an output cannot also be a signed // input in the same stage. - if other, exist := signByPath[signDirective.OutputPath()]; exist && other.Before == signDirective.Before && other.After == signDirective.After { + if other, exist := signByPath[signDirective.OutputPath()]; exist && !other.IsPattern() && other.Before == signDirective.Before && other.After == signDirective.After { return nil, newDetailedConfigError(fmt.Sprintf("conflicting `path: %s` for sign: its signature `%s` is signed at the same stage!", signDirective.Path, signDirective.OutputPath()), rawSign, c.doc) } if other, exist := signByOutputPath[signDirective.Path]; exist && other.Before == signDirective.Before && other.After == signDirective.After { return nil, newDetailedConfigError(fmt.Sprintf("conflicting `path: %s` for sign: it is the signature of `path: %s` signed at the same stage!", signDirective.Path, other.Path), rawSign, c.doc) } - signByPath[signDirective.Path] = signDirective signByOutputPath[signDirective.OutputPath()] = signDirective - - imageBase.Sign = append(imageBase.Sign, signDirective) } for _, rawDep := range c.RawDependencies { diff --git a/pkg/config/raw_stapel_image_test.go b/pkg/config/raw_stapel_image_test.go index b2e278012a..244e5d23c7 100644 --- a/pkg/config/raw_stapel_image_test.go +++ b/pkg/config/raw_stapel_image_test.go @@ -117,6 +117,34 @@ var _ = Describe("rawStapelImage", func() { {Path: "/out/a.roothash.p7s"}, }, + platform: []string{}, + final: true, + }, + }, + ), + Entry( + "should handle sign with glob patterns, whose conflicts are checked against the files they expand to", + map[string]interface{}{ + "image": "image1", + "from": "alpine:latest", + "sign": []map[string]interface{}{ + {"path": "/out/lib/modules/**/*.ko", "after": "install"}, + {"path": "/out/*.ko.p7s", "after": "install"}, + {"path": "/out/lib/modules/**/*.ko.p7s", "after": "install"}, + }, + }, + &StapelImage{ + StapelImageBase: &StapelImageBase{ + Name: "image1", + From: "alpine:latest", + Git: &GitManager{}, + Secrets: []Secret{}, + Sign: []*Sign{ + {Path: "/out/lib/modules/**/*.ko", After: "install"}, + {Path: "/out/*.ko.p7s", After: "install"}, + {Path: "/out/lib/modules/**/*.ko.p7s", After: "install"}, + }, + platform: []string{}, final: true, }, @@ -452,6 +480,14 @@ var _ = Describe("rawStapelImage", func() { }, }, ), + Entry( + "with sign malformed glob pattern", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/out/[x.ko", "after": "install"}}, + }, + ), Entry( "with sign path and its signature at the end of the image", map[string]interface{}{ diff --git a/pkg/config/sign.go b/pkg/config/sign.go index 278b74fa26..a12a01cfc5 100644 --- a/pkg/config/sign.go +++ b/pkg/config/sign.go @@ -3,11 +3,15 @@ package config import ( "fmt" "path" + "strings" + + "github.com/bmatcuk/doublestar/v4" ) -// Sign declares a file of the image whose detached PKCS#7 signature werf writes -// next to it as .p7s, right after the stage named by Before or After, or at -// the end of the image when neither is set. +// Sign declares files of the image whose detached PKCS#7 signatures werf writes next +// to them as .p7s, right after the stage named by Before or After, or at the end +// of the image when neither is set. Path is either one absolute file path or an absolute +// glob pattern expanded against the image filesystem when the signing stage runs. type Sign struct { Path string Before string @@ -20,8 +24,19 @@ func (c *Sign) GetRaw() interface{} { return c.raw } +// IsPattern reports whether Path is a glob pattern rather than one file path. +func (c *Sign) IsPattern() bool { + return strings.ContainsAny(c.Path, "*?[{") +} + +// OutputPath is the signature path of a single-file entry; it is meaningless for a +// pattern, whose files are known only at signing time. func (c *Sign) OutputPath() string { - return c.Path + ".p7s" + return SignatureOutputPath(c.Path) +} + +func SignatureOutputPath(filePath string) string { + return filePath + ".p7s" } func (c *Sign) validate() error { @@ -29,7 +44,9 @@ func (c *Sign) validate() error { case c.Path == "": return newDetailedConfigError("`path: /PATH/TO/FILE` required for sign!", c.raw, c.raw.rawStapelImage.doc) case !path.IsAbs(c.Path) || path.Clean(c.Path) != c.Path || c.Path == "/": - return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path!", c.Path), c.raw, c.raw.rawStapelImage.doc) + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path or glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) + case c.IsPattern() && !doublestar.ValidatePattern(c.Path): + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: malformed glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && c.After != "": return newDetailedConfigError("specify only one stage using `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && checkInvalidRelation(c.Before): diff --git a/pkg/container_backend/buildah_image_reader.go b/pkg/container_backend/buildah_image_reader.go index d5216acf1a..1f3caec571 100644 --- a/pkg/container_backend/buildah_image_reader.go +++ b/pkg/container_backend/buildah_image_reader.go @@ -2,41 +2,131 @@ package container_backend import ( "context" + "errors" "fmt" + "io/fs" "os" + "path" + "path/filepath" + "sort" + + "github.com/bmatcuk/doublestar/v4" "github.com/werf/logboek" ) func (backend *BuildahBackend) ReadFileFromImage(ctx context.Context, imageRef, path string, opts ReadFileFromImageOpts) ([]byte, error) { - containers, err := backend.createContainers(ctx, []string{imageRef}, CommonOpts(opts)) + reader, err := backend.OpenImageReader(ctx, imageRef, opts) if err != nil { return nil, err } - container := containers[0] defer func() { - if err := backend.removeContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { - logboek.Context(ctx).Error().LogF("ERROR: unable to remove temporal container %q: %s\n", container.Name, err) + if err := reader.Close(ctx); err != nil { + logboek.Context(ctx).Error().LogF("ERROR: %s\n", err) } }() + return reader.ReadFile(ctx, path) +} + +var _ ImageReader = (*buildahImageReader)(nil) + +// buildahImageReader reads paths out of one container of the image, mounted once in +// OpenImageReader and released in Close. +type buildahImageReader struct { + backend *BuildahBackend + imageRef string + container *containerDesc + opts CommonOpts +} + +func (backend *BuildahBackend) OpenImageReader(ctx context.Context, imageRef string, opts ReadFileFromImageOpts) (ImageReader, error) { + containers, err := backend.createContainers(ctx, []string{imageRef}, CommonOpts(opts)) + if err != nil { + return nil, err + } + container := containers[0] + if err := backend.mountContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { + if rmErr := backend.removeContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); rmErr != nil { + logboek.Context(ctx).Error().LogF("ERROR: unable to remove temporal container %q: %s\n", container.Name, rmErr) + } return nil, fmt.Errorf("mount container %q: %w", container.Name, err) } - defer func() { - if err := backend.unmountContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { - logboek.Context(ctx).Error().LogF("ERROR: unable to unmount container %q: %s\n", container.Name, err) - } - }() - data, err := readRegularFileFromRoot(container.RootMount, path) - if err != nil { - return nil, fmt.Errorf("read %s from image %q: %w", path, imageRef, err) + return &buildahImageReader{backend: backend, imageRef: imageRef, container: container, opts: CommonOpts(opts)}, nil +} + +// Close removes the container even when unmounting it fails: the caller only logs the +// error, so this is the last chance to release the container. +func (r *buildahImageReader) Close(ctx context.Context) error { + var errs []error + if err := r.backend.unmountContainers(ctx, []*containerDesc{r.container}, r.opts); err != nil { + errs = append(errs, fmt.Errorf("unmount container %q: %w", r.container.Name, err)) } + if err := r.backend.removeContainers(ctx, []*containerDesc{r.container}, r.opts); err != nil { + errs = append(errs, fmt.Errorf("remove container %q: %w", r.container.Name, err)) + } + return errors.Join(errs...) +} +func (r *buildahImageReader) ReadFile(_ context.Context, path string) ([]byte, error) { + data, err := readRegularFileFromRoot(r.container.RootMount, path) + if err != nil { + return nil, fmt.Errorf("read %s from image %q: %w", path, r.imageRef, err) + } return data, nil } +// Glob walks the pattern's literal base directory inside the mounted root. The base is +// resolved against the container root, so a symlink there never leads to a host path; +// below it the walk does not descend into symlinks at all. A missing base is no match. +func (r *buildahImageReader) Glob(_ context.Context, pattern string) ([]string, error) { + base, _ := doublestar.SplitPattern(pattern) + resolvedBase, err := resolveContainerRootPath(r.container.RootMount, base) + if err != nil { + return nil, err + } + + var matches []string + err = filepath.WalkDir(resolvedBase, func(hostPath string, d fs.DirEntry, err error) error { + if err != nil { + if hostPath == resolvedBase && os.IsNotExist(err) { + return filepath.SkipAll + } + return err + } + if !d.Type().IsRegular() { + return nil + } + rel, err := filepath.Rel(resolvedBase, hostPath) + if err != nil { + return err + } + filePath := path.Join(base, filepath.ToSlash(rel)) + matched, err := doublestar.Match(pattern, filePath) + if err != nil { + return fmt.Errorf("match %s against %q: %w", filePath, pattern, err) + } + if matched { + matches = append(matches, filePath) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("glob %q in image %q: %w", pattern, r.imageRef, err) + } + sort.Strings(matches) + return matches, nil +} + +// ReadDir is intentionally unsupported on the Buildah backend: its only caller is the +// SBOM directory-scan flow, which rejects Buildah upfront (see the SBOM guard in the +// conveyor). +func (r *buildahImageReader) ReadDir(_ context.Context, _, _ string, _ ReadDirOpts) error { + return fmt.Errorf("ReadDir is not supported with the Buildah backend") +} + // readRegularFileFromRoot reads path inside the mounted container root, following // symlinks against that root rather than the host, and accepts regular files only, // matching what the Docker reader yields for the same path. @@ -55,15 +145,8 @@ func readRegularFileFromRoot(rootMount, path string) ([]byte, error) { return os.ReadFile(resolvedPath) } -// OpenImageReader is intentionally unsupported on the Buildah backend: the only caller is -// the SBOM directory-scan flow, which rejects Buildah upfront (see the SBOM guard in the -// conveyor), so a ContainerBackend backed by Buildah never reaches an image reader. -func (backend *BuildahBackend) OpenImageReader(ctx context.Context, imageRef string, opts ReadFileFromImageOpts) (ImageReader, error) { - return nil, fmt.Errorf("OpenImageReader is not supported with the Buildah backend") -} - -// RunCommandInImage is unsupported on the Buildah backend for the same reason as -// OpenImageReader: its only caller is the SBOM flow, which rejects Buildah upfront. +// RunCommandInImage is unsupported on the Buildah backend: its only caller is the SBOM +// flow, which rejects Buildah upfront. func (backend *BuildahBackend) RunCommandInImage(ctx context.Context, imageRef string, opts RunCommandInImageOpts) ([]byte, error) { return nil, fmt.Errorf("RunCommandInImage is not supported with the Buildah backend") } diff --git a/pkg/container_backend/buildah_image_reader_test.go b/pkg/container_backend/buildah_image_reader_test.go index 74bfb47a79..71d1053090 100644 --- a/pkg/container_backend/buildah_image_reader_test.go +++ b/pkg/container_backend/buildah_image_reader_test.go @@ -44,4 +44,34 @@ var _ = Describe("BuildahBackend ReadFileFromImage path resolution", func() { _, err = readRegularFileFromRoot(root, "/out/dangling") Expect(err).To(MatchError(os.ErrNotExist)) }) + + Describe("Glob", func() { + It("lists the regular files under the resolved base directory that the pattern matches, without following symlinks", func(ctx SpecContext) { + base := GinkgoT().TempDir() + root := filepath.Join(base, "root") + modules := filepath.Join(root, "usr", "lib", "modules", "6.12", "kernel") + Expect(os.MkdirAll(filepath.Join(modules, "net"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "a.ko"), []byte("a"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "net", "b.ko"), []byte("b"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "net", "b.ko.d"), []byte("d"), 0o644)).To(Succeed()) + Expect(os.Symlink("a.ko", filepath.Join(modules, "link.ko"))).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(base, "host"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(base, "host", "c.ko"), []byte("host"), 0o644)).To(Succeed()) + Expect(os.Symlink("../../../../../host", filepath.Join(modules, "escape"))).To(Succeed()) + Expect(os.Symlink("/usr/lib", filepath.Join(root, "lib"))).To(Succeed()) + + reader := &buildahImageReader{container: &containerDesc{RootMount: root}} + + Expect(reader.Glob(ctx, "/lib/modules/**/*.ko")).To(Equal([]string{ + "/lib/modules/6.12/kernel/a.ko", + "/lib/modules/6.12/kernel/net/b.ko", + }), "the base directory is resolved through the container's own symlink, below it symlinks are not followed") + }) + + It("matches nothing when the base directory does not exist", func(ctx SpecContext) { + reader := &buildahImageReader{container: &containerDesc{RootMount: GinkgoT().TempDir()}} + + Expect(reader.Glob(ctx, "/out/**/*.ko")).To(BeEmpty()) + }) + }) }) diff --git a/pkg/container_backend/docker_image_reader.go b/pkg/container_backend/docker_image_reader.go index f0fa3b71bd..e77db75475 100644 --- a/pkg/container_backend/docker_image_reader.go +++ b/pkg/container_backend/docker_image_reader.go @@ -4,13 +4,17 @@ import ( "archive/tar" "bytes" "context" + "errors" "fmt" "io" "io/fs" "os" + "path" "path/filepath" + "sort" "strings" + "github.com/bmatcuk/doublestar/v4" "github.com/docker/docker/client" "github.com/google/uuid" @@ -73,6 +77,20 @@ func (r *dockerImageReader) ReadFile(ctx context.Context, path string) ([]byte, return collector.result() } +// Glob streams the pattern's literal base directory out of the container and keeps the +// regular-file entries the pattern matches. A missing base directory is no match. +func (r *dockerImageReader) Glob(ctx context.Context, pattern string) ([]string, error) { + collector := newGlobTarCollector(pattern) + err := r.copy(ctx, collector.base, collector.collect) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return collector.result(), nil +} + // fileTarCollector takes the content of the object a `docker cp ` stream was // asked for. That object is the stream's first entry and whatever it contains follows // it, so a directory has to be refused there: otherwise an arbitrary file inside it @@ -112,6 +130,51 @@ func (c *fileTarCollector) result() ([]byte, error) { return c.data, nil } +// globTarCollector matches the regular-file entries of a `docker cp ` stream +// against a doublestar pattern whose literal prefix is base. The stream names its +// entries from the base name of the copied directory on, so they are rebased onto its +// parent to get the in-image path the pattern is written for. +type globTarCollector struct { + base string + pattern string + regular map[string]struct{} + matches []string +} + +func newGlobTarCollector(pattern string) *globTarCollector { + base, _ := doublestar.SplitPattern(pattern) + return &globTarCollector{base: base, pattern: pattern, regular: map[string]struct{}{}} +} + +// collect keeps the regular files. The second and later names of one inode arrive as +// hard links to the first name, so those count when the first was a regular file. +func (c *globTarCollector) collect(_ *tar.Reader, hdr *tar.Header) error { + switch hdr.Typeflag { + case tar.TypeReg: + c.regular[hdr.Name] = struct{}{} + case tar.TypeLink: + if _, ok := c.regular[hdr.Linkname]; !ok { + return nil + } + default: + return nil + } + filePath := path.Join(path.Dir(c.base), hdr.Name) + matched, err := doublestar.Match(c.pattern, filePath) + if err != nil { + return fmt.Errorf("match %s against %q: %w", filePath, c.pattern, err) + } + if matched { + c.matches = append(c.matches, filePath) + } + return nil +} + +func (c *globTarCollector) result() []string { + sort.Strings(c.matches) + return c.matches +} + func (r *dockerImageReader) ReadDir(ctx context.Context, path, destDir string, opts ReadDirOpts) error { extractor := newDirTarExtractor(path, destDir, opts.FileNamePatterns) if err := r.copy(ctx, path, extractor.extract); err != nil { diff --git a/pkg/container_backend/docker_image_reader_test.go b/pkg/container_backend/docker_image_reader_test.go index 9f0569357b..5e095c869e 100644 --- a/pkg/container_backend/docker_image_reader_test.go +++ b/pkg/container_backend/docker_image_reader_test.go @@ -20,7 +20,7 @@ var _ = Describe("fileTarCollector", func() { // feed mimics what `docker cp ` streams: the requested object first, named // by its base name, followed by whatever it contains. - feed := func(collector *fileTarCollector, entries []entry) error { + feed := func(collect func(*tar.Reader, *tar.Header) error, entries []entry) error { var buf bytes.Buffer tw := tar.NewWriter(&buf) for _, e := range entries { @@ -43,7 +43,7 @@ var _ = Describe("fileTarCollector", func() { return nil } Expect(err).To(Succeed()) - if err := collector.collect(tr, hdr); err != nil { + if err := collect(tr, hdr); err != nil { return err } } @@ -52,7 +52,7 @@ var _ = Describe("fileTarCollector", func() { It("reads the requested regular file", func() { collector := &fileTarCollector{path: "/out/ext.roothash", imageRef: "image"} - Expect(feed(collector, []entry{ + Expect(feed(collector.collect, []entry{ {name: "ext.roothash", typeflag: tar.TypeReg, content: "roothash"}, })).To(Succeed()) Expect(collector.result()).To(Equal([]byte("roothash"))) @@ -61,7 +61,7 @@ var _ = Describe("fileTarCollector", func() { It("refuses a directory instead of returning a file it contains", func() { collector := &fileTarCollector{path: "/out", imageRef: "image"} - err := feed(collector, []entry{ + err := feed(collector.collect, []entry{ {name: "out/", typeflag: tar.TypeDir}, {name: "out/ext.roothash", typeflag: tar.TypeReg, content: "roothash"}, }) @@ -71,11 +71,47 @@ var _ = Describe("fileTarCollector", func() { It("reports a path that holds no regular file as not existing", func() { collector := &fileTarCollector{path: "/out/link", imageRef: "image"} - Expect(feed(collector, []entry{ + Expect(feed(collector.collect, []entry{ {name: "link", typeflag: tar.TypeSymlink, linkname: "/elsewhere"}, })).To(Succeed()) _, err := collector.result() Expect(err).To(MatchError(fs.ErrNotExist)) }) + + Describe("globTarCollector", func() { + It("rebases the entries of the copied base directory onto the image path and keeps the regular files the pattern matches, hard links included, sorted", func() { + collector := newGlobTarCollector("/out/lib/modules/**/*.ko") + Expect(collector.base).To(Equal("/out/lib/modules")) + + Expect(feed(collector.collect, []entry{ + {name: "modules", typeflag: tar.TypeDir}, + {name: "modules/6.12", typeflag: tar.TypeDir}, + {name: "modules/6.12/kernel/net/b.ko", typeflag: tar.TypeReg, content: "b"}, + {name: "modules/6.12/kernel/a.ko", typeflag: tar.TypeReg, content: "a"}, + {name: "modules/6.12/modules.order", typeflag: tar.TypeReg, content: "order"}, + {name: "modules/6.12/kernel/link.ko", typeflag: tar.TypeSymlink, linkname: "a.ko"}, + {name: "modules/6.12/kernel/hard.ko", typeflag: tar.TypeLink, linkname: "modules/6.12/kernel/a.ko"}, + {name: "modules/6.12/kernel/hard-to-link.ko", typeflag: tar.TypeLink, linkname: "modules/6.12/kernel/link.ko"}, + })).To(Succeed()) + + Expect(collector.result()).To(Equal([]string{ + "/out/lib/modules/6.12/kernel/a.ko", + "/out/lib/modules/6.12/kernel/hard.ko", + "/out/lib/modules/6.12/kernel/net/b.ko", + }), "a hard link to a regular file is that file under another name; a hard link to a symlink is not") + }) + + It("matches a file directly under the base directory when the pattern allows it", func() { + collector := newGlobTarCollector("/out/*.roothash") + + Expect(feed(collector.collect, []entry{ + {name: "out", typeflag: tar.TypeDir}, + {name: "out/ext.roothash", typeflag: tar.TypeReg, content: "x"}, + {name: "out/sub/ext.roothash", typeflag: tar.TypeReg, content: "y"}, + })).To(Succeed()) + + Expect(collector.result()).To(Equal([]string{"/out/ext.roothash"})) + }) + }) }) diff --git a/pkg/container_backend/interface.go b/pkg/container_backend/interface.go index ecb61a56ff..6ad010eb66 100644 --- a/pkg/container_backend/interface.go +++ b/pkg/container_backend/interface.go @@ -53,6 +53,10 @@ type ImageReader interface { // ReadFile returns the content of the regular file at path. When there is no // regular file at path, the returned error wraps fs.ErrNotExist. ReadFile(ctx context.Context, path string) ([]byte, error) + // Glob returns the sorted absolute paths of the regular files matching pattern, an + // absolute doublestar pattern. Symlinks are neither matched nor followed, so a file + // is listed under its real path only. No match is not an error. + Glob(ctx context.Context, pattern string) ([]string, error) // ReadDir copies the directory tree at path into destDir on the host, preserving // the layout relative to path. Only regular files are written; a symlink to a // directory inside the tree is materialized as a copy of its target. When there is diff --git a/schemas/werf.json b/schemas/werf.json index f9d7878db9..88306f3f60 100644 --- a/schemas/werf.json +++ b/schemas/werf.json @@ -1515,7 +1515,7 @@ ], "properties": { "path": { - "description": "The absolute path of the file to sign.", + "description": "The absolute path of the file to sign, or an absolute glob pattern expanded against the image filesystem when the signing stage runs.", "type": "string", "pattern": "^/" }, diff --git a/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml b/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml new file mode 100644 index 0000000000..729f8e5c0f --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml @@ -0,0 +1,23 @@ +project: werf-test-e2e-sign-files-glob +configVersion: 1 +--- +image: kernel +from: alpine:3.20 +shell: + install: + - mkdir -p /out/lib/modules/6.12/kernel/net + - printf '%s' 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b > /out/lib/modules/6.12/kernel/a.ko + - printf '%s' 9f2c04a1b8e3d6570ac41b2e8d93f5061c7ab38e4d2f90b5c6137ae8d40f2b9c > /out/lib/modules/6.12/kernel/net/b.ko + - printf '%s' 'kernel/a.ko' > /out/lib/modules/6.12/modules.order + - ln -s a.ko /out/lib/modules/6.12/kernel/link.ko + - ln /out/lib/modules/6.12/kernel/a.ko /out/lib/modules/6.12/kernel/hard.ko + setup: + - test -s /out/lib/modules/6.12/kernel/a.ko.p7s + - test -s /out/lib/modules/6.12/kernel/net/b.ko.p7s + - test -s /out/lib/modules/6.12/kernel/hard.ko.p7s + - "! cmp -s /out/lib/modules/6.12/kernel/a.ko.p7s /out/lib/modules/6.12/kernel/net/b.ko.p7s" + - "! test -e /out/lib/modules/6.12/modules.order.p7s" + - "! test -e /out/lib/modules/6.12/kernel/link.ko.p7s" +sign: +- path: /out/lib/modules/**/*.ko + after: install diff --git a/test/e2e/build/sign_files_test.go b/test/e2e/build/sign_files_test.go index 365e6284d3..94350cb39c 100644 --- a/test/e2e/build/sign_files_test.go +++ b/test/e2e/build/sign_files_test.go @@ -1,6 +1,7 @@ package e2e_build_test import ( + "archive/tar" "crypto" "crypto/rsa" "crypto/sha256" @@ -170,6 +171,56 @@ var _ = Describe("sign directive", Label("e2e", "sign-files", "simple"), func() }}), ) + DescribeTable("signs every regular file a glob pattern matches", + func(ctx SpecContext, testOpts signFilesTestOptions) { + setupEnv(testOpts.setupEnvOptions) + + SuiteData.InitTestRepo(ctx, "repo0", "sign_files/state_glob") + werfProject := report.NewProjectWithReport(werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath("repo0"))) + + buildOut, buildReport := werfProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("report0.json"), &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: signArgs}}) + Expect(buildOut).To(ContainSubstring("Signing /out/lib/modules/6.12/kernel/a.ko")) + Expect(buildOut).To(ContainSubstring("Signing /out/lib/modules/6.12/kernel/net/b.ko")) + Expect(buildOut).NotTo(ContainSubstring("Signing /out/lib/modules/6.12/modules.order")) + Expect(buildOut).To(ContainSubstring("Building stage kernel/setup")) + + files := map[string][]byte{} + hardLinks := map[string]string{} + img := loadImageFromRegistry(buildReport.Images["kernel"].DockerImageName) + utils.ForEachImageFile(SuiteData.TmpDir, img, func(header *elfTar.Header, tmpLocalPath string) { + name := "/" + strings.TrimPrefix(header.Name, "/") + if !strings.HasPrefix(name, "/out/lib/modules/") { + return + } + if header.Typeflag == tar.TypeLink { + hardLinks[name] = "/" + strings.TrimPrefix(header.Linkname, "/") + return + } + if tmpLocalPath == "" { + return + } + data, err := os.ReadFile(tmpLocalPath) + Expect(err).To(Succeed()) + files[name] = data + }) + for name, target := range hardLinks { + files[name] = files[target] + } + for _, module := range []string{"/out/lib/modules/6.12/kernel/a.ko", "/out/lib/modules/6.12/kernel/hard.ko", "/out/lib/modules/6.12/kernel/net/b.ko"} { + Expect(files).To(HaveKey(module + ".p7s")) + expectSystemdProfile(files[module], files[module+".p7s"]) + } + }, + backendEntry("with repo using Docker", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "docker", + WithLocalRepo: true, + }}), + backendEntry("with repo using Native Rootless", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "native-rootless", + WithLocalRepo: true, + }}), + ) + DescribeTable("keeps the build offline under build.sbom.enable and still signs", func(ctx SpecContext, testOpts signFilesTestOptions) { setupEnv(testOpts.setupEnvOptions) diff --git a/test/mock/container_backend.go b/test/mock/container_backend.go index b1fb3bfa4f..4b234fd230 100644 --- a/test/mock/container_backend.go +++ b/test/mock/container_backend.go @@ -60,6 +60,21 @@ func (mr *MockImageReaderMockRecorder) Close(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockImageReader)(nil).Close), ctx) } +// Glob mocks base method. +func (m *MockImageReader) Glob(ctx context.Context, pattern string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Glob", ctx, pattern) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Glob indicates an expected call of Glob. +func (mr *MockImageReaderMockRecorder) Glob(ctx, pattern any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Glob", reflect.TypeOf((*MockImageReader)(nil).Glob), ctx, pattern) +} + // ReadDir mocks base method. func (m *MockImageReader) ReadDir(ctx context.Context, path, destDir string, opts container_backend.ReadDirOpts) error { m.ctrl.T.Helper() From 7d3848fe6328b107ba68a0665c1fe9d113c1eebb Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Mon, 26 Oct 2026 12:24:24 +0300 Subject: [PATCH 2/3] fix(build, stapel): reject a sign pattern rooted at / and document its metacharacters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pattern whose literal base is `/` (`/**/*.ko`) streams the whole image filesystem out through `docker cp` for one signing stage; require a literal directory below `/` instead. `*`, `?`, `[` and `{` in `path` are now metacharacters, so a literal file name containing one is written with a backslash — say so in the docs and the skill, and state that symlinks are not followed below the base rather than "never": the base itself resolves through the container root. Signed-off-by: Radmir Khurum --- .agents/skills/werf-to-delivery-kit/SKILL.md | 2 +- docs/pages_en/usage/build/stapel/imports.md | 4 +++- docs/pages_ru/usage/build/stapel/imports.md | 4 +++- pkg/build/stage/sign_files.go | 7 ++----- pkg/config/raw_stapel_image_test.go | 8 ++++++++ pkg/config/sign.go | 12 +++++++++++- pkg/container_backend/buildah_image_reader.go | 4 +--- pkg/container_backend/docker_image_reader.go | 5 +++-- 8 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.agents/skills/werf-to-delivery-kit/SKILL.md b/.agents/skills/werf-to-delivery-kit/SKILL.md index 709e6274ff..f2784aed65 100644 --- a/.agents/skills/werf-to-delivery-kit/SKILL.md +++ b/.agents/skills/werf-to-delivery-kit/SKILL.md @@ -332,7 +332,7 @@ import: - Формат подписи фиксирован: отсоединённый PKCS#7 (CMS) `SignedData`, SHA-256, RSASSA-PKCS1-v1_5, без подписываемых атрибутов, со встроенным leaf-сертификатом (intermediates не встраиваются) — как у `systemd-keyutil pkcs7` / `openssl smime -sign -binary -noattr -outform der`. Ключ только RSA. Если старая сборка делала то же самое — переписывать на `sign:`; иначе (в т.ч. потребителю нужна встроенная цепочка) — стоп-условие. - Все `sign:` одного запуска delivery-kit используют **одну** пару ключ/сертификат. Сверить, каким ключом подписывается каждый файл; если в конфиге сейчас разные ключи для разных файлов — остановиться и согласовать с пользователем, не объединять ключи и не менять CI. -- `path` — абсолютный путь к обычному файлу (не каталог) либо абсолютный glob-шаблон (`*`, `?`, `[...]`, `{a,b}`, `**`; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: каждый подошедший обычный файл получает свой `.p7s`, симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. +- `path` — абсолютный путь к обычному файлу (не каталог) либо абсолютный glob-шаблон (`*`, `?`, `[...]`, `{a,b}`, `**`; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: шаблон начинается с буквального каталога ниже `/`, каждый подошедший обычный файл получает свой `.p7s`, ниже базового каталога симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. `*?[{` в `path` — метасимволы: литеральный файл с таким символом в имени экранируется `\` (`/out/file\[1\].bin`). Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. - Проверку подписи, которая раньше шла сразу после подписания, не удалять — перенести: стадия, которая производит файл, своей подписи не видит. Файл из `install` → `after: install`, проверка в `beforeSetup`/`setup`; файл из `beforeSetup` → `before: setup`, проверка в `setup`; файл из `setup` (последняя shell-стадия) → подпись без `before`/`after`, проверка в отдельном образе, который импортирует файл и `.p7s`. Если сохранить проверку так не получается — стоп-условие. Если проверке нужен `openssl` — пакет в `spec:` os-pm. - Зависимые образы, которые импортировали подписанный файл, импортируют и `.p7s`. - Удалить вход в Vault и вызовы подписи из shell. `secrets:` с `VAULT_*`/JWT, `mount:` с клиентом Vault (`d8`, `vault`), запрос сертификата из shell и соответствующие `secrets.allowEnvVariables`/`mount.allowFromPaths` в `werf-giterminism.yaml` удалять только после проверки всех их использований: если они нужны другому образу или проверке подписи (например, CA-сертификат) — оставить. diff --git a/docs/pages_en/usage/build/stapel/imports.md b/docs/pages_en/usage/build/stapel/imports.md index ae74b8c8ad..f85f6a8681 100644 --- a/docs/pages_en/usage/build/stapel/imports.md +++ b/docs/pages_en/usage/build/stapel/imports.md @@ -113,7 +113,7 @@ sign: after: install ``` -`path` may also be a glob pattern (`*`, `?`, `[...]`, `{a,b}`, `**`), expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. Symlinks are neither matched nor followed, so a file is signed under its real path only, and a pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: +`path` may also be a glob pattern (`*`, `?`, `[...]`, `{a,b}`, `**`), expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. The pattern must start with a literal directory below `/` (`/out/**/*.ko`, not `/**/*.ko`). Below that directory symlinks are neither matched nor followed, so a file is signed under its real path only; a hard link is a regular file under another name and is signed under each. A pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: ```yaml sign: @@ -121,6 +121,8 @@ sign: after: install ``` +Because of this, `*`, `?`, `[` and `{` are metacharacters in `path`: a file whose name contains one of them is written with a backslash before it, `path: /out/file\[1\].bin`, otherwise the entry is expanded as a pattern and signs whatever that pattern matches. + Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. The same holds for the files a pattern expands to, and a file reached through two entries is an error as well; both are detected when the pattern is expanded. To sign a generated signature, put the second entry at a later signing position. werf reads the file out of the image built so far, signs it on the build host and adds `.p7s` as a new layer. The signature is a detached PKCS#7 (CMS) `SignedData` over the file content — SHA-256, RSASSA-PKCS1-v1_5, no authenticated attributes, the signing certificate embedded — the same shape `systemd-keyutil pkcs7` produces, so systemd and the Linux kernel accept it as a dm-verity root hash signature. The key therefore has to be RSA. diff --git a/docs/pages_ru/usage/build/stapel/imports.md b/docs/pages_ru/usage/build/stapel/imports.md index d7ccab1102..452591f9cc 100644 --- a/docs/pages_ru/usage/build/stapel/imports.md +++ b/docs/pages_ru/usage/build/stapel/imports.md @@ -113,7 +113,7 @@ sign: after: install ``` -`path` может быть и glob-шаблоном (`*`, `?`, `[...]`, `{a,b}`, `**`): он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. Символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём, а шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: +`path` может быть и glob-шаблоном (`*`, `?`, `[...]`, `{a,b}`, `**`): он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. Шаблон должен начинаться с буквального каталога ниже `/` (`/out/**/*.ko`, но не `/**/*.ko`). Ниже этого каталога символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём; жёсткая ссылка — это обычный файл под другим именем, он подписывается под каждым. Шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: ```yaml sign: @@ -121,6 +121,8 @@ sign: after: install ``` +Поэтому `*`, `?`, `[` и `{` в `path` — метасимволы: файл, в имени которого есть один из них, записывается с обратной косой чертой перед ним, `path: /out/file\[1\].bin`, иначе запись раскрывается как шаблон и подписывает то, что этому шаблону соответствует. + Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. То же относится к файлам, в которые раскрывается шаблон; файл, достижимый через две записи, — тоже ошибка. И то и другое обнаруживается при раскрытии шаблона. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. werf читает файл из собранного к этому моменту образа, подписывает его на сборочном хосте и добавляет `.p7s` новым слоем. Подпись — отсоединённый PKCS#7 (CMS) `SignedData` над содержимым файла: SHA-256, RSASSA-PKCS1-v1_5, без аутентифицированных атрибутов, со встроенным сертификатом подписанта — ровно та форма, которую производит `systemd-keyutil pkcs7`, поэтому systemd и ядро Linux принимают её как подпись dm-verity root hash. Ключ, соответственно, должен быть RSA. diff --git a/pkg/build/stage/sign_files.go b/pkg/build/stage/sign_files.go index 81368046e6..dc5a5550f8 100644 --- a/pkg/build/stage/sign_files.go +++ b/pkg/build/stage/sign_files.go @@ -208,11 +208,8 @@ func (s *SignFilesStage) PrepareImage(ctx context.Context, c Conveyor, cb contai return nil } -// expandSigns resolves the stage's entries to the files to sign, in entry order. A -// pattern that matches nothing fails the build, like a single path that does not exist: -// a declared signature is a requirement of the image. The same file reached through two -// entries, or a file that is the signature of another file of this stage, is rejected -// here for patterns the way the config parser rejects it for literal paths. +// expandSigns resolves the entries to the files to sign, in entry order, and applies to +// the expanded set the conflict checks the config parser applies to literal paths. func (s *SignFilesStage) expandSigns(ctx context.Context, reader container_backend.ImageReader) ([]string, error) { var files []string signByFile := map[string]*config.Sign{} diff --git a/pkg/config/raw_stapel_image_test.go b/pkg/config/raw_stapel_image_test.go index 244e5d23c7..7655805013 100644 --- a/pkg/config/raw_stapel_image_test.go +++ b/pkg/config/raw_stapel_image_test.go @@ -480,6 +480,14 @@ var _ = Describe("rawStapelImage", func() { }, }, ), + Entry( + "with sign glob pattern rooted at /", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/**/*.ko", "after": "install"}}, + }, + ), Entry( "with sign malformed glob pattern", map[string]interface{}{ diff --git a/pkg/config/sign.go b/pkg/config/sign.go index a12a01cfc5..feccef4f26 100644 --- a/pkg/config/sign.go +++ b/pkg/config/sign.go @@ -11,7 +11,8 @@ import ( // Sign declares files of the image whose detached PKCS#7 signatures werf writes next // to them as .p7s, right after the stage named by Before or After, or at the end // of the image when neither is set. Path is either one absolute file path or an absolute -// glob pattern expanded against the image filesystem when the signing stage runs. +// glob pattern expanded against the image filesystem when the signing stage runs; `*`, +// `?`, `[` and `{` are metacharacters, a literal one is escaped with a backslash. type Sign struct { Path string Before string @@ -39,6 +40,13 @@ func SignatureOutputPath(filePath string) string { return filePath + ".p7s" } +// patternBase is the literal directory a pattern is expanded under; the whole image +// filesystem is too much to stream out for one signing stage. +func patternBase(pattern string) string { + base, _ := doublestar.SplitPattern(pattern) + return base +} + func (c *Sign) validate() error { switch { case c.Path == "": @@ -47,6 +55,8 @@ func (c *Sign) validate() error { return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path or glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) case c.IsPattern() && !doublestar.ValidatePattern(c.Path): return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: malformed glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) + case c.IsPattern() && patternBase(c.Path) == "/": + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: a glob pattern must start with a literal directory below /!", c.Path), c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && c.After != "": return newDetailedConfigError("specify only one stage using `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && checkInvalidRelation(c.Before): diff --git a/pkg/container_backend/buildah_image_reader.go b/pkg/container_backend/buildah_image_reader.go index 1f3caec571..ab7b8744d4 100644 --- a/pkg/container_backend/buildah_image_reader.go +++ b/pkg/container_backend/buildah_image_reader.go @@ -120,9 +120,7 @@ func (r *buildahImageReader) Glob(_ context.Context, pattern string) ([]string, return matches, nil } -// ReadDir is intentionally unsupported on the Buildah backend: its only caller is the -// SBOM directory-scan flow, which rejects Buildah upfront (see the SBOM guard in the -// conveyor). +// ReadDir's only caller, the SBOM directory scan, rejects the Buildah backend upfront. func (r *buildahImageReader) ReadDir(_ context.Context, _, _ string, _ ReadDirOpts) error { return fmt.Errorf("ReadDir is not supported with the Buildah backend") } diff --git a/pkg/container_backend/docker_image_reader.go b/pkg/container_backend/docker_image_reader.go index e77db75475..80af1460cc 100644 --- a/pkg/container_backend/docker_image_reader.go +++ b/pkg/container_backend/docker_image_reader.go @@ -132,8 +132,9 @@ func (c *fileTarCollector) result() ([]byte, error) { // globTarCollector matches the regular-file entries of a `docker cp ` stream // against a doublestar pattern whose literal prefix is base. The stream names its -// entries from the base name of the copied directory on, so they are rebased onto its -// parent to get the in-image path the pattern is written for. +// entries from the base name of the copied directory on (`out/a.ko` for base `/out`), +// so joining them onto the base's parent gives the in-image path the pattern is written +// for. A base of `/` never reaches here: the config rejects it. type globTarCollector struct { base string pattern string From 737366ff32b31e7ac3e4f8eab2f5525d356ad920 Mon Sep 17 00:00:00 2001 From: Radmir Khurum Date: Mon, 26 Oct 2026 13:02:48 +0300 Subject: [PATCH 3/3] fix(build, stapel): make * the only metacharacter of a sign pattern `sign[].path` was a literal path before glob support, so turning `?`, `[` and `{` into syntax changed the meaning of existing names such as `/out/file[1].bin` and forced a backslash-escaping rule nobody needs: signing wants "every .ko below this directory" and nothing more. A path is a pattern when it contains `*`; every other character stands for itself, which is done by escaping it before the pattern reaches doublestar. Signed-off-by: Radmir Khurum --- .agents/skills/werf-to-delivery-kit/SKILL.md | 2 +- docs/pages_en/usage/build/stapel/imports.md | 4 +-- docs/pages_ru/usage/build/stapel/imports.md | 4 +-- pkg/build/stage/sign_files.go | 2 +- pkg/build/stage/sign_files_test.go | 28 ++++++++++++++++++++ pkg/config/raw_stapel_image_test.go | 8 ------ pkg/config/sign.go | 21 ++++++++++----- 7 files changed, 46 insertions(+), 23 deletions(-) diff --git a/.agents/skills/werf-to-delivery-kit/SKILL.md b/.agents/skills/werf-to-delivery-kit/SKILL.md index f2784aed65..4fcefc04e8 100644 --- a/.agents/skills/werf-to-delivery-kit/SKILL.md +++ b/.agents/skills/werf-to-delivery-kit/SKILL.md @@ -332,7 +332,7 @@ import: - Формат подписи фиксирован: отсоединённый PKCS#7 (CMS) `SignedData`, SHA-256, RSASSA-PKCS1-v1_5, без подписываемых атрибутов, со встроенным leaf-сертификатом (intermediates не встраиваются) — как у `systemd-keyutil pkcs7` / `openssl smime -sign -binary -noattr -outform der`. Ключ только RSA. Если старая сборка делала то же самое — переписывать на `sign:`; иначе (в т.ч. потребителю нужна встроенная цепочка) — стоп-условие. - Все `sign:` одного запуска delivery-kit используют **одну** пару ключ/сертификат. Сверить, каким ключом подписывается каждый файл; если в конфиге сейчас разные ключи для разных файлов — остановиться и согласовать с пользователем, не объединять ключи и не менять CI. -- `path` — абсолютный путь к обычному файлу (не каталог) либо абсолютный glob-шаблон (`*`, `?`, `[...]`, `{a,b}`, `**`; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: шаблон начинается с буквального каталога ниже `/`, каждый подошедший обычный файл получает свой `.p7s`, ниже базового каталога симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. `*?[{` в `path` — метасимволы: литеральный файл с таким символом в имени экранируется `\` (`/out/file\[1\].bin`). Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. +- `path` — абсолютный путь к обычному файлу (не каталог) либо, если содержит `*`, абсолютный glob-шаблон (`*` в пределах сегмента, `**` через сегменты; остальные символы, включая `?[{`, буквальные — экранирование не нужно; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: шаблон начинается с буквального каталога ниже `/`, каждый подошедший обычный файл получает свой `.p7s`, ниже базового каталога симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. - Проверку подписи, которая раньше шла сразу после подписания, не удалять — перенести: стадия, которая производит файл, своей подписи не видит. Файл из `install` → `after: install`, проверка в `beforeSetup`/`setup`; файл из `beforeSetup` → `before: setup`, проверка в `setup`; файл из `setup` (последняя shell-стадия) → подпись без `before`/`after`, проверка в отдельном образе, который импортирует файл и `.p7s`. Если сохранить проверку так не получается — стоп-условие. Если проверке нужен `openssl` — пакет в `spec:` os-pm. - Зависимые образы, которые импортировали подписанный файл, импортируют и `.p7s`. - Удалить вход в Vault и вызовы подписи из shell. `secrets:` с `VAULT_*`/JWT, `mount:` с клиентом Vault (`d8`, `vault`), запрос сертификата из shell и соответствующие `secrets.allowEnvVariables`/`mount.allowFromPaths` в `werf-giterminism.yaml` удалять только после проверки всех их использований: если они нужны другому образу или проверке подписи (например, CA-сертификат) — оставить. diff --git a/docs/pages_en/usage/build/stapel/imports.md b/docs/pages_en/usage/build/stapel/imports.md index f85f6a8681..2a0ec55b01 100644 --- a/docs/pages_en/usage/build/stapel/imports.md +++ b/docs/pages_en/usage/build/stapel/imports.md @@ -113,7 +113,7 @@ sign: after: install ``` -`path` may also be a glob pattern (`*`, `?`, `[...]`, `{a,b}`, `**`), expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. The pattern must start with a literal directory below `/` (`/out/**/*.ko`, not `/**/*.ko`). Below that directory symlinks are neither matched nor followed, so a file is signed under its real path only; a hard link is a regular file under another name and is signed under each. A pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: +A `path` containing `*` is a glob pattern, expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. `*` matches within one path segment and `**` across segments; every other character, `?`, `[` and `{` included, stands for itself, so a literal path such as `/out/file[1].bin` needs no escaping (a file with `*` in its name cannot be signed). The pattern must start with a literal directory below `/` (`/out/**/*.ko`, not `/**/*.ko`). Below that directory symlinks are neither matched nor followed, so a file is signed under its real path only; a hard link is a regular file under another name and is signed under each. A pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: ```yaml sign: @@ -121,8 +121,6 @@ sign: after: install ``` -Because of this, `*`, `?`, `[` and `{` are metacharacters in `path`: a file whose name contains one of them is written with a backslash before it, `path: /out/file\[1\].bin`, otherwise the entry is expanded as a pattern and signs whatever that pattern matches. - Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. The same holds for the files a pattern expands to, and a file reached through two entries is an error as well; both are detected when the pattern is expanded. To sign a generated signature, put the second entry at a later signing position. werf reads the file out of the image built so far, signs it on the build host and adds `.p7s` as a new layer. The signature is a detached PKCS#7 (CMS) `SignedData` over the file content — SHA-256, RSASSA-PKCS1-v1_5, no authenticated attributes, the signing certificate embedded — the same shape `systemd-keyutil pkcs7` produces, so systemd and the Linux kernel accept it as a dm-verity root hash signature. The key therefore has to be RSA. diff --git a/docs/pages_ru/usage/build/stapel/imports.md b/docs/pages_ru/usage/build/stapel/imports.md index 452591f9cc..2d28f7175f 100644 --- a/docs/pages_ru/usage/build/stapel/imports.md +++ b/docs/pages_ru/usage/build/stapel/imports.md @@ -113,7 +113,7 @@ sign: after: install ``` -`path` может быть и glob-шаблоном (`*`, `?`, `[...]`, `{a,b}`, `**`): он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. Шаблон должен начинаться с буквального каталога ниже `/` (`/out/**/*.ko`, но не `/**/*.ko`). Ниже этого каталога символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём; жёсткая ссылка — это обычный файл под другим именем, он подписывается под каждым. Шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: +`path`, содержащий `*`, — glob-шаблон: он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. `*` соответствует части одного сегмента пути, `**` — любому числу сегментов; все остальные символы, включая `?`, `[` и `{`, обозначают сами себя, поэтому буквальный путь вроде `/out/file[1].bin` не требует экранирования (файл с `*` в имени подписать нельзя). Шаблон должен начинаться с буквального каталога ниже `/` (`/out/**/*.ko`, но не `/**/*.ko`). Ниже этого каталога символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём; жёсткая ссылка — это обычный файл под другим именем, он подписывается под каждым. Шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: ```yaml sign: @@ -121,8 +121,6 @@ sign: after: install ``` -Поэтому `*`, `?`, `[` и `{` в `path` — метасимволы: файл, в имени которого есть один из них, записывается с обратной косой чертой перед ним, `path: /out/file\[1\].bin`, иначе запись раскрывается как шаблон и подписывает то, что этому шаблону соответствует. - Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. То же относится к файлам, в которые раскрывается шаблон; файл, достижимый через две записи, — тоже ошибка. И то и другое обнаруживается при раскрытии шаблона. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. werf читает файл из собранного к этому моменту образа, подписывает его на сборочном хосте и добавляет `.p7s` новым слоем. Подпись — отсоединённый PKCS#7 (CMS) `SignedData` над содержимым файла: SHA-256, RSASSA-PKCS1-v1_5, без аутентифицированных атрибутов, со встроенным сертификатом подписанта — ровно та форма, которую производит `systemd-keyutil pkcs7`, поэтому systemd и ядро Linux принимают её как подпись dm-verity root hash. Ключ, соответственно, должен быть RSA. diff --git a/pkg/build/stage/sign_files.go b/pkg/build/stage/sign_files.go index dc5a5550f8..e000abe4a0 100644 --- a/pkg/build/stage/sign_files.go +++ b/pkg/build/stage/sign_files.go @@ -217,7 +217,7 @@ func (s *SignFilesStage) expandSigns(ctx context.Context, reader container_backe matches := []string{sign.Path} if sign.IsPattern() { var err error - matches, err = reader.Glob(ctx, sign.Path) + matches, err = reader.Glob(ctx, sign.Pattern()) if err != nil { return nil, fmt.Errorf("expand `path: %s` of sign in image %q: %w", sign.Path, s.imageName, err) } diff --git a/pkg/build/stage/sign_files_test.go b/pkg/build/stage/sign_files_test.go index 2845584848..df88309a9e 100644 --- a/pkg/build/stage/sign_files_test.go +++ b/pkg/build/stage/sign_files_test.go @@ -300,6 +300,34 @@ var _ = Describe("SignFilesStage", func() { Expect(verifies(filepath.Join(signaturesDir, "1", "b.ko.p7s"), files["/out/lib/modules/6.12/kernel/net/b.ko"])).To(Succeed()) }) + It("treats every character but * literally, so file names with ?, [ or { need no escaping", func(ctx SpecContext) { + files := map[string][]byte{ + "/out/lib[64]/a.ko": []byte("a"), + "/out/lib6/a.ko": []byte("not under lib[64]"), + "/out/file[1].bin": []byte("literal path"), + "/out/file1.bin": []byte("would match [1] as a class"), + "/out/{x}/what?.bin": []byte("braces and question mark"), + "/out/{x}/what!.bin": []byte("would match ? as a wildcard"), + "/out/lib[64]/b.ko.bak": []byte("not a .ko"), + } + + signaturesDir, err := prepare(ctx, files, + &config.Sign{Path: "/out/lib[64]/*.ko", After: "install"}, + &config.Sign{Path: "/out/file[1].bin", After: "install"}, + &config.Sign{Path: "/out/{x}/what?.b*", After: "install"}, + ) + Expect(err).NotTo(HaveOccurred()) + + script, err := os.ReadFile(filepath.Join(signaturesDir, "install.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(script)).To(ContainSubstring("/out/lib[64]/a.ko.p7s")) + Expect(string(script)).To(ContainSubstring("/out/file[1].bin.p7s")) + Expect(string(script)).To(ContainSubstring("/out/{x}/what?.bin.p7s")) + Expect(string(script)).NotTo(ContainSubstring("lib6/")) + Expect(string(script)).NotTo(ContainSubstring("file1.bin")) + Expect(string(script)).NotTo(ContainSubstring("what!.bin")) + }) + It("fails when the pattern matches no file, since a declared signature is a requirement of the image", func(ctx SpecContext) { _, err := prepare(ctx, map[string][]byte{"/out/notes.txt": []byte("x")}, &config.Sign{Path: "/out/**/*.ko", After: "install"}) Expect(err).To(MatchError(ContainSubstring("`path: /out/**/*.ko` of sign in image \"kernel\" matches no file"))) diff --git a/pkg/config/raw_stapel_image_test.go b/pkg/config/raw_stapel_image_test.go index 7655805013..a570631b24 100644 --- a/pkg/config/raw_stapel_image_test.go +++ b/pkg/config/raw_stapel_image_test.go @@ -488,14 +488,6 @@ var _ = Describe("rawStapelImage", func() { "sign": []map[string]interface{}{{"path": "/**/*.ko", "after": "install"}}, }, ), - Entry( - "with sign malformed glob pattern", - map[string]interface{}{ - "image": "image1", - "from": "alpine", - "sign": []map[string]interface{}{{"path": "/out/[x.ko", "after": "install"}}, - }, - ), Entry( "with sign path and its signature at the end of the image", map[string]interface{}{ diff --git a/pkg/config/sign.go b/pkg/config/sign.go index feccef4f26..ccdb592f6c 100644 --- a/pkg/config/sign.go +++ b/pkg/config/sign.go @@ -10,9 +10,10 @@ import ( // Sign declares files of the image whose detached PKCS#7 signatures werf writes next // to them as .p7s, right after the stage named by Before or After, or at the end -// of the image when neither is set. Path is either one absolute file path or an absolute -// glob pattern expanded against the image filesystem when the signing stage runs; `*`, -// `?`, `[` and `{` are metacharacters, a literal one is escaped with a backslash. +// of the image when neither is set. Path is either one absolute file path or, when it +// contains `*`, an absolute glob pattern expanded against the image filesystem when the +// signing stage runs. `*` (and `**`) is the only metacharacter; every other character, +// `?`, `[` and `{` included, stands for itself. type Sign struct { Path string Before string @@ -27,9 +28,17 @@ func (c *Sign) GetRaw() interface{} { // IsPattern reports whether Path is a glob pattern rather than one file path. func (c *Sign) IsPattern() bool { - return strings.ContainsAny(c.Path, "*?[{") + return strings.Contains(c.Path, "*") } +// Pattern is Path as a doublestar pattern: the characters doublestar would otherwise +// read as syntax are escaped, so that only `*` matches anything but itself. +func (c *Sign) Pattern() string { + return doublestarEscaper.Replace(c.Path) +} + +var doublestarEscaper = strings.NewReplacer(`\`, `\\`, `?`, `\?`, `[`, `\[`, `]`, `\]`, `{`, `\{`, `}`, `\}`) + // OutputPath is the signature path of a single-file entry; it is meaningless for a // pattern, whose files are known only at signing time. func (c *Sign) OutputPath() string { @@ -53,9 +62,7 @@ func (c *Sign) validate() error { return newDetailedConfigError("`path: /PATH/TO/FILE` required for sign!", c.raw, c.raw.rawStapelImage.doc) case !path.IsAbs(c.Path) || path.Clean(c.Path) != c.Path || c.Path == "/": return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path or glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) - case c.IsPattern() && !doublestar.ValidatePattern(c.Path): - return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: malformed glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) - case c.IsPattern() && patternBase(c.Path) == "/": + case c.IsPattern() && patternBase(c.Pattern()) == "/": return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: a glob pattern must start with a literal directory below /!", c.Path), c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && c.After != "": return newDetailedConfigError("specify only one stage using `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc)