From d70d7ecaba199b8cb2d71834df874c1ee23652ff Mon Sep 17 00:00:00 2001 From: Maksim An Date: Tue, 11 Aug 2026 01:55:12 -0700 Subject: [PATCH] securitypolicy: add Confidential WCOW policy generation Extend the securitypolicy package and tooling to generate rego policies for Confidential WCOW (Windows) images, mirroring the existing C-LCOW image-based flow. The runtime enforcement side (verified CIM mounts) already existed; this adds the authoring side so a matching policy can be produced from an image reference. Key changes: - pkg/securitypolicy: add WindowsContainerConfig (image-based) and CreateWindowsContainerPolicy, WithWindows* opts, and Windows rego/ fragment marshalling. Windows policy is rego only; the JSON marshaller is rejected for Windows. regoString() escapes backslashes/quotes so Windows paths and user strings survive marshalling. - internal/tools/securitypolicy: add -os linux|windows and route marshalling per OS; PolicyWindowsContainersFromConfigs fetches the windows platform image and computes verified Block CIM digests. - helpers/windows.go: import each layer into a verified single-file Block CIM and read its sealed root digest. Each layer is imported into its own directory with CimName "layer.cim" (merged: "merged.cim"), exactly matching containerd's cimfs snapshotter/differ, since the embedded CimName contributes to the sealed root digest. Setting SECURITYPOLICY_BLOCKCIM_DIR preserves the intermediate *.bcim for inspection. Existing LCOW policy generation behavior is intentionally preserved; the Windows path is only taken for windows_container config entries. Signed-off-by: Maksim An --- internal/tools/securitypolicy/README.md | 179 +++----------- .../tools/securitypolicy/helpers/helpers.go | 84 +++++++ .../tools/securitypolicy/helpers/windows.go | 150 +++++++++++ .../securitypolicy/helpers/windows_others.go | 16 ++ .../securitypolicy/helpers/windows_test.go | 21 ++ internal/tools/securitypolicy/main.go | 108 +++++--- pkg/securitypolicy/opts.go | 77 ++++++ pkg/securitypolicy/securitypolicy.go | 78 ++++-- pkg/securitypolicy/securitypolicy_internal.go | 23 ++ pkg/securitypolicy/securitypolicy_marshal.go | 232 +++++++++++++----- pkg/securitypolicy/windows_tooling_test.go | 145 +++++++++++ test/pkg/securitypolicy/policy.go | 57 +++++ 12 files changed, 907 insertions(+), 263 deletions(-) create mode 100644 internal/tools/securitypolicy/helpers/windows.go create mode 100644 internal/tools/securitypolicy/helpers/windows_others.go create mode 100644 internal/tools/securitypolicy/helpers/windows_test.go create mode 100644 pkg/securitypolicy/windows_tooling_test.go diff --git a/internal/tools/securitypolicy/README.md b/internal/tools/securitypolicy/README.md index 26cbfe8753..433cb97ebc 100644 --- a/internal/tools/securitypolicy/README.md +++ b/internal/tools/securitypolicy/README.md @@ -9,11 +9,44 @@ It is not intended to be used by "end users" but could be used as a basis for such a tool. A Base64 encoded version of policy is sent as an annotation to GCS for processing. -The `securitypolicy` tool will, by default, output Base64 encoded JSON. +The `securitypolicy` tool outputs a Base64 encoded Rego policy. Running the tool can take a long time as each layer for each container must be downloaded, turned into an ext4, and finally a dm-verity root hash calculated. +For C-WCOW policies, use `-os windows`. Like C-LCOW, the input is image-based: +give an `image_name` and the tool computes the verified Block CIM layer digests +and the merged CIM digest for you (via `ImportBlockCIMLayerWithOpts` + +`MergeBlockCIMLayersWithOpts`). This computation uses the Windows CIM APIs, so +`-os windows` must run on Windows (and typically elevated). Windows policies +support Rego output only. + +A single config file can hold both `[[container]]` (C-LCOW) and +`[[windows_container]]` (C-WCOW) sections. The `-os` flag selects which section +is used; the other OS's container entries are ignored. + +## C-WCOW example + +```toml +[[windows_container]] +image_name = "mcr.microsoft.com/windows/nanoserver:ltsc2022" +command = ["cmd.exe", "/c", "echo hello"] +working_dir = "C:\\" +user = "ContainerUser" +allow_stdio_access = true + +[[windows_container.env_rule]] +strategy = "string" +rule = "PATH=C:\\Windows\\system32;C:\\Windows" +required = true +``` + +Generate a raw and Base64-encoded Rego policy (on Windows): + + securitypolicytool -c windows.toml -os windows -t rego -r + +`-t fragment` also works with `-os windows` to emit a Windows policy fragment. + ## Example TOML configuration file ```toml @@ -65,149 +98,6 @@ minimum_svn = "1" include = ["containers"] ``` -### Converted to JSON - -The result of the command: - - securitypolicytool -c sample.toml -t json -r - -The above TOML configuration gets translated into the appropriate policy that is -represented in JSON. - -```json -{ - "allow_all": false, - "containers": { - "length": 2, - "elements": { - "0": { - "command": { - "length": 2, - "elements": { - "0": "rustc", - "1": "--help" - } - }, - "env_rules": { - "length": 6, - "elements": { - "0": { - "strategy": "string", - "rule": "PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "required": false - }, - "1": { - "strategy": "string", - "rule": "RUSTUP_HOME=/usr/local/rustup", - "required": false - }, - "2": { - "strategy": "string", - "rule": "CARGO_HOME=/usr/local/cargo", - "required": false - }, - "3": { - "strategy": "string", - "rule": "RUST_VERSION=1.52.1", - "required": false - }, - "4": { - "strategy": "string", - "rule": "TERM=xterm", - "required": false - }, - "5": { - "strategy": "re2", - "rule": "PREFIX_.+=.+", - "required": false - } - } - }, - "layers": { - "length": 6, - "elements": { - "0": "fe84c9d5bfddd07a2624d00333cf13c1a9c941f3a261f13ead44fc6a93bc0e7a", - "1": "4dedae42847c704da891a28c25d32201a1ae440bce2aecccfa8e6f03b97a6a6c", - "2": "41d64cdeb347bf236b4c13b7403b633ff11f1cf94dbc7cf881a44d6da88c5156", - "3": "eb36921e1f82af46dfe248ef8f1b3afb6a5230a64181d960d10237a08cd73c79", - "4": "e769d7487cc314d3ee748a4440805317c19262c7acd2fdbdb0d47d2e4613a15c", - "5": "1b80f120dbd88e4355d6241b519c3e25290215c469516b49dece9cf07175a766" - } - }, - "working_dir": "/home/user", - "mounts": { - "length": 2, - "elements": { - "0": { - "source": "sandbox:///host/path/one", - "destination": "/container/path/one", - "type": "bind", - "options": { - "length": 3, - "elements": { - "0": "rbind", - "1": "rshared", - "2": "rw" - } - } - }, - "1": { - "source": "sandbox:///host/path/two", - "destination": "/container/path/two", - "type": "bind", - "options": { - "length": 3, - "elements": { - "0": "rbind", - "1": "rshared", - "2": "ro" - } - } - } - } - }, - "allow_elevated": true - }, - "1": { - "command": { - "length": 1, - "elements": { - "0": "/pause" - } - }, - "env_rules": { - "length": 2, - "elements": { - "0": { - "strategy": "string", - "rule": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "required": false - }, - "1": { - "strategy": "string", - "rule": "TERM=xterm", - "required": false - } - } - }, - "layers": { - "length": 1, - "elements": { - "0": "16b514057a06ad665f92c02863aca074fd5976c755d26bff16365299169e8415" - } - }, - "working_dir": "/", - "mounts": { - "length": 0, - "elements": {} - }, - "allow_elevated": false - } - } - } -} -``` - ## Converted to Rego Policy The result of the command: @@ -387,7 +277,6 @@ output raw marshaled policy in addition to the base64 one of: - `rego`: outputs a Rego policy -- `json`: outputs a legacy JSON policy (NOTE: some TOML elements are not supported in the legacy format) - `fragment`: outputs a Rego fragment. The `-n` and `-v` are required for this option. ### `-n` diff --git a/internal/tools/securitypolicy/helpers/helpers.go b/internal/tools/securitypolicy/helpers/helpers.go index 99f90b8ac4..90b7fb6c2f 100644 --- a/internal/tools/securitypolicy/helpers/helpers.go +++ b/internal/tools/securitypolicy/helpers/helpers.go @@ -1,10 +1,12 @@ package helpers import ( + "context" "encoding/json" "fmt" "log" "os" + "runtime" "strconv" "strings" "syscall" @@ -255,6 +257,88 @@ func PolicyContainersFromConfigs(containerConfigs []sp.ContainerConfig) ([]*sp.C return policyContainers, nil } +// PolicyWindowsContainersFromConfigs returns Windows container policies built +// from configs. The verified Block CIM layer digests and merged CIM digest are +// computed from each config's image, which requires running on Windows. +func PolicyWindowsContainersFromConfigs(ctx context.Context, containerConfigs []sp.WindowsContainerConfig) ([]*sp.WindowsContainer, error) { + policyContainers := make([]*sp.WindowsContainer, 0, len(containerConfigs)) + for _, config := range containerConfigs { + if config.ImageName == "" { + return nil, fmt.Errorf("windows container config requires image_name") + } + + var imageOptions []remote.Option + if config.Auth.Username != "" && config.Auth.Password != "" { + auth := authn.Basic{ + Username: config.Auth.Username, + Password: config.Auth.Password, + } + c, _ := auth.Authorization() + imageOptions = append(imageOptions, remote.WithAuth(authn.FromConfig(*c))) + } + // Windows image indexes have no linux child; select the windows platform. + imageOptions = append(imageOptions, remote.WithPlatform(v1.Platform{OS: "windows", Architecture: runtime.GOARCH})) + + img, err := RemoteImageFromImageName(config.ImageName, imageOptions...) + if err != nil { + return nil, fmt.Errorf("unable to fetch image: %w", err) + } + + layers, mergedCim, err := computeWindowsImageDigests(ctx, img) + if err != nil { + return nil, err + } + + command := config.Command + if len(command) == 0 { + if command, err = ParseCommandFromImage(img); err != nil { + return nil, err + } + } + + imgConfig, err := img.ConfigFile() + if err != nil { + return nil, err + } + workingDir := config.WorkingDir + if workingDir == "" { + workingDir = imgConfig.Config.WorkingDir + } + user := config.User + if user == "" { + user = imgConfig.Config.User + } + // Environment variables baked into the image are required. + envRules := append(sp.NewEnvVarRules(imgConfig.Config.Env, true), config.EnvRules...) + + // mergedCim is the digest of the CIM the runtime actually mounts: the + // merged CIM for multi-layer images, or the lone layer's own CIM for + // single-layer images. Either way it is the mounted CIM digest. + if mergedCim == "" { + return nil, fmt.Errorf("image %q produced an empty mounted CIM digest", config.ImageName) + } + mountedCims := []string{mergedCim} + + container, err := sp.CreateWindowsContainerPolicy( + command, + layers, + mountedCims, + envRules, + workingDir, + config.ExecProcesses, + config.Signals, + config.AllowStdioAccess, + user, + ) + if err != nil { + return nil, err + } + policyContainers = append(policyContainers, container) + } + + return policyContainers, nil +} + func setDefaultUser(config *sp.UserConfig, user, group sp.IDNameConfig) sp.UserConfig { if config != nil { return *config diff --git a/internal/tools/securitypolicy/helpers/windows.go b/internal/tools/securitypolicy/helpers/windows.go new file mode 100644 index 0000000000..e68ed5c4be --- /dev/null +++ b/internal/tools/securitypolicy/helpers/windows.go @@ -0,0 +1,150 @@ +//go:build windows + +package helpers + +import ( + "context" + "fmt" + "os" + "path/filepath" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/sirupsen/logrus" + + "github.com/Microsoft/hcsshim/internal/log" + "github.com/Microsoft/hcsshim/pkg/cimfs" + ociwclayercim "github.com/Microsoft/hcsshim/pkg/ociwclayer/cim" +) + +// keepBlockCIMEnvVar names a directory where the intermediate Block CIMs are +// written and preserved (instead of a temp dir that is deleted). It exists so +// the generated layer.vhd / merged.vhd block CIM files can be inspected (e.g. +// with the blockcimdump tool) to debug digest mismatches against containerd's +// layer.vhd. +const keepBlockCIMEnvVar = "SECURITYPOLICY_BLOCKCIM_DIR" + +// containerd's cimfs snapshotter names every layer's block CIM "layer.cim" +// (each layer in its own directory) and the merged CIM "merged.cim". The CimName +// is embedded in the block CIM's PrimaryBlockTable and therefore contributes to +// the sealed root digest, so the tooling must use the exact same names (and one +// directory per layer) to reproduce the digests the runtime computes. +const ( + containerdLayerCIMName = "layer.cim" + containerdMergedCIMName = "merged.cim" +) + +// computeWindowsImageDigests imports each image layer into a verified Block CIM, +// merges them, and returns the per-layer root digests (base-to-top, matching +// img.Layers()) plus the merged CIM root digest. This mirrors the digests the +// runtime computes when mounting verified CIMs, so the resulting policy matches +// enforcement. It requires the Windows CIM APIs (cimfs) and typically elevation. +func computeWindowsImageDigests(ctx context.Context, img v1.Image) (_ []string, _ string, err error) { + imgLayers, err := img.Layers() + if err != nil { + return nil, "", err + } + if len(imgLayers) == 0 { + return nil, "", fmt.Errorf("image has no layers") + } + + // When SECURITYPOLICY_BLOCKCIM_DIR is set, write the Block CIMs there and + // keep them for inspection; otherwise use a temp dir that is cleaned up. + tmpDir := os.Getenv(keepBlockCIMEnvVar) + if tmpDir != "" { + if err = os.MkdirAll(tmpDir, 0755); err != nil { + return nil, "", fmt.Errorf("create block CIM output dir %q: %w", tmpDir, err) + } + log.G(ctx).WithField("dir", tmpDir).Warn("preserving intermediate Block CIMs") + } else { + tmpDir, err = os.MkdirTemp("", "securitypolicy-wcow-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(tmpDir) + } + + blockCIMs := make([]*cimfs.BlockCIM, len(imgLayers)) + layerDigests := make([]string, len(imgLayers)) + + // Import base-to-top so each layer's parents are already imported. + for i, layer := range imgLayers { + r, err := layer.Uncompressed() + if err != nil { + return nil, "", err + } + + blockCIMs[i] = &cimfs.BlockCIM{ + Type: cimfs.BlockCIMTypeSingleFile, + // One directory per layer so every layer's block file can share the + // containerd CimName "layer.cim" without colliding. + BlockPath: filepath.Join(tmpDir, fmt.Sprintf("layer%d", i), "layer.vhd"), + CimName: containerdLayerCIMName, + } + + // Parent layers are ordered immediate-parent-first. + var parents []*cimfs.BlockCIM + for j := i - 1; j >= 0; j-- { + parents = append(parents, blockCIMs[j]) + } + + // Single-file verified CIM matches containerd's CWCOW extraction. The VHD + // footer is skipped: it's appended after sealing so it doesn't affect the + // root digest, and the block CIMs are discarded once digests are read. + importOpts := []ociwclayercim.BlockCIMLayerImportOpt{ociwclayercim.WithLayerIntegrity()} + if len(parents) > 0 { + importOpts = append(importOpts, ociwclayercim.WithParentLayers(parents)) + } + + _, importErr := ociwclayercim.ImportBlockCIMLayerWithOpts(ctx, r, blockCIMs[i], importOpts...) + if cerr := r.Close(); cerr != nil && importErr == nil { + importErr = cerr + } + if importErr != nil { + return nil, "", fmt.Errorf("import layer %d into block CIM: %w", i, importErr) + } + + digest, err := ociwclayercim.GetIntegrityChecksum(ctx, blockCIMs[i].BlockPath, "") + if err != nil { + return nil, "", fmt.Errorf("get layer %d digest: %w", i, err) + } + layerDigests[i] = digest + log.G(ctx).WithFields(logrus.Fields{ + "layer": i, + "path": blockCIMs[i].BlockPath, + "digest": digest, + }).Debug("imported Block CIM layer") + } + + // A single-layer image has nothing to merge: the runtime attaches only the + // lone verified layer CIM (mergedCIM is nil), so the mounted CIM digest the + // runtime reports is that layer's own digest. + if len(blockCIMs) < 2 { + return layerDigests, layerDigests[0], nil + } + + // MergeBlockCIMLayersWithOpts expects source CIMs ordered topmost-to-base. + sourceCIMs := make([]*cimfs.BlockCIM, len(blockCIMs)) + for i, c := range blockCIMs { + sourceCIMs[len(blockCIMs)-1-i] = c + } + + mergedCIM := &cimfs.BlockCIM{ + Type: cimfs.BlockCIMTypeSingleFile, + BlockPath: filepath.Join(tmpDir, "merged", "merged.vhd"), + CimName: containerdMergedCIMName, + } + if err := ociwclayercim.MergeBlockCIMLayersWithOpts(ctx, sourceCIMs, mergedCIM, ociwclayercim.WithLayerIntegrity()); err != nil { + return nil, "", fmt.Errorf("merge block CIM layers: %w", err) + } + + mergedDigest, err := ociwclayercim.GetIntegrityChecksum(ctx, mergedCIM.BlockPath, "") + if err != nil { + return nil, "", fmt.Errorf("get merged CIM digest: %w", err) + } + log.G(ctx).WithFields(logrus.Fields{ + "path": mergedCIM.BlockPath, + "digest": mergedDigest, + }).Debug("merged Block CIM layers") + + return layerDigests, mergedDigest, nil +} diff --git a/internal/tools/securitypolicy/helpers/windows_others.go b/internal/tools/securitypolicy/helpers/windows_others.go new file mode 100644 index 0000000000..4733e72926 --- /dev/null +++ b/internal/tools/securitypolicy/helpers/windows_others.go @@ -0,0 +1,16 @@ +//go:build !windows + +package helpers + +import ( + "context" + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// computeWindowsImageDigests is unavailable off Windows because it relies on the +// Windows CIM APIs. Callers must supply precomputed layers and mounted_cim. +func computeWindowsImageDigests(_ context.Context, _ v1.Image) ([]string, string, error) { + return nil, "", fmt.Errorf("computing Windows container layer digests requires running on Windows; provide precomputed layers and mounted_cim instead") +} diff --git a/internal/tools/securitypolicy/helpers/windows_test.go b/internal/tools/securitypolicy/helpers/windows_test.go new file mode 100644 index 0000000000..d46b13934e --- /dev/null +++ b/internal/tools/securitypolicy/helpers/windows_test.go @@ -0,0 +1,21 @@ +package helpers + +import ( + "context" + "strings" + "testing" + + sp "github.com/Microsoft/hcsshim/pkg/securitypolicy" +) + +func TestPolicyWindowsContainersFromConfigsRequiresImage(t *testing.T) { + _, err := PolicyWindowsContainersFromConfigs(context.Background(), []sp.WindowsContainerConfig{{ + Command: []string{"cmd.exe"}, + }}) + if err == nil { + t.Fatal("expected error when image_name is not set") + } + if !strings.Contains(err.Error(), "image_name") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/tools/securitypolicy/main.go b/internal/tools/securitypolicy/main.go index e210708216..f05d0e4231 100644 --- a/internal/tools/securitypolicy/main.go +++ b/internal/tools/securitypolicy/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/base64" "flag" "fmt" @@ -14,7 +15,8 @@ import ( var ( configFile = flag.String("c", "", "config path") - outputType = flag.String("t", "", "[rego|json|fragment]") + outputType = flag.String("t", "", "[rego|fragment]") + guestOS = flag.String("os", "linux", "guest OS [linux|windows]") fragmentNamespace = flag.String("n", "", "fragment namespace") fragmentSVN = flag.String("v", "", "fragment svn") outputRaw = flag.Bool("r", false, "whether to print the raw output") @@ -40,37 +42,85 @@ func main() { return err } - defaultContainers := helpers.DefaultContainerConfigs() - config.Containers = append(config.Containers, defaultContainers...) - policyContainers, err := helpers.PolicyContainersFromConfigs(config.Containers) - if err != nil { - return err - } - var policyCode string if *outputType == "fragment" { - policyCode, err = securitypolicy.MarshalFragment( - *fragmentNamespace, - *fragmentSVN, - policyContainers, - config.ExternalProcesses, - config.Fragments) + switch *guestOS { + case "linux": + // windows_container entries are ignored when targeting linux. + config.Containers = append(config.Containers, helpers.DefaultContainerConfigs()...) + policyContainers, cerr := helpers.PolicyContainersFromConfigs(config.Containers) + if cerr != nil { + return cerr + } + policyCode, err = securitypolicy.MarshalFragment( + *fragmentNamespace, + *fragmentSVN, + policyContainers, + config.ExternalProcesses, + config.Fragments) + case "windows": + // container (linux) entries are ignored when targeting windows. + policyContainers, cerr := helpers.PolicyWindowsContainersFromConfigs(context.Background(), config.WindowsContainers) + if cerr != nil { + return cerr + } + policyCode, err = securitypolicy.MarshalWindowsFragment( + *fragmentNamespace, + *fragmentSVN, + policyContainers, + config.ExternalProcesses, + config.Fragments) + default: + return fmt.Errorf("unsupported guest OS %q", *guestOS) + } } else { - policyCode, err = securitypolicy.MarshalPolicy( - *outputType, - config.AllowAll, - policyContainers, - config.ExternalProcesses, - config.Fragments, - config.AllowPropertiesAccess, - config.AllowDumpStacks, - config.AllowRuntimeLogging, - config.AllowHostNetwork, - config.AllowEnvironmentVariableDropping, - config.AllowUnencryptedScratch, - config.AllowCapabilityDropping, - config.AllowLogProviderDropping, - ) + switch *guestOS { + case "linux": + // windows_container entries are ignored when targeting linux. + config.Containers = append(config.Containers, helpers.DefaultContainerConfigs()...) + policyContainers, cerr := helpers.PolicyContainersFromConfigs(config.Containers) + if cerr != nil { + return cerr + } + policyCode, err = securitypolicy.MarshalPolicy( + *outputType, + config.AllowAll, + policyContainers, + config.ExternalProcesses, + config.Fragments, + config.AllowPropertiesAccess, + config.AllowDumpStacks, + config.AllowRuntimeLogging, + config.AllowHostNetwork, + config.AllowEnvironmentVariableDropping, + config.AllowUnencryptedScratch, + config.AllowCapabilityDropping, + config.AllowLogProviderDropping, + ) + case "windows": + // container (linux) entries are ignored when targeting windows. + policyContainers, cerr := helpers.PolicyWindowsContainersFromConfigs(context.Background(), config.WindowsContainers) + if cerr != nil { + return cerr + } + policyCode, err = securitypolicy.MarshalWindowsPolicy( + *outputType, + config.AllowAll, + policyContainers, + config.ExternalProcesses, + config.Fragments, + config.AllowPropertiesAccess, + config.AllowDumpStacks, + config.AllowRuntimeLogging, + config.AllowHostNetwork, + config.AllowEnvironmentVariableDropping, + config.AllowUnencryptedScratch, + config.AllowCapabilityDropping, + config.AllowLogProviderDropping, + ) + default: + return fmt.Errorf("unsupported guest OS %q", *guestOS) + } } if err != nil { return err diff --git a/pkg/securitypolicy/opts.go b/pkg/securitypolicy/opts.go index e917868dbf..f3f9a24517 100644 --- a/pkg/securitypolicy/opts.go +++ b/pkg/securitypolicy/opts.go @@ -1,7 +1,11 @@ package securitypolicy +import "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" + type ContainerConfigOpt func(config *ContainerConfig) error +type WindowsContainerConfigOpt func(config *WindowsContainerConfig) error + type PolicyConfigOpt func(config *PolicyConfig) error // WithEnvVarRules adds environment variable constraints to container policy config. @@ -101,6 +105,79 @@ func WithContainers(containers []ContainerConfig) PolicyConfigOpt { } } +// WithWindowsImageName sets the image whose verified Block CIM digests the +// tooling computes for a Windows container policy config. +func WithWindowsImageName(imageName string) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.ImageName = imageName + return nil + } +} + +// WithWindowsCommand sets the command in a Windows container policy config. +func WithWindowsCommand(command []string) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.Command = command + return nil + } +} + +// WithWindowsEnvVarRules adds environment variable constraints to a Windows container policy config. +func WithWindowsEnvVarRules(envs []EnvRuleConfig) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.EnvRules = append(config.EnvRules, envs...) + return nil + } +} + +// WithWindowsWorkingDir sets the Windows container working directory. +func WithWindowsWorkingDir(workingDir string) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.WorkingDir = workingDir + return nil + } +} + +// WithWindowsExecProcesses adds allowed exec processes to a Windows container policy config. +func WithWindowsExecProcesses(processes []WindowsExecProcessConfig) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.ExecProcesses = append(config.ExecProcesses, processes...) + return nil + } +} + +// WithWindowsSignals sets the signals allowed for the Windows container init process. +func WithWindowsSignals(signals []guestrequest.SignalValueWCOW) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.Signals = signals + return nil + } +} + +// WithWindowsAllowStdioAccess enables or disables Windows container init process stdio. +func WithWindowsAllowStdioAccess(allow bool) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.AllowStdioAccess = allow + return nil + } +} + +// WithWindowsUser sets the Windows container user. +func WithWindowsUser(user string) WindowsContainerConfigOpt { + return func(config *WindowsContainerConfig) error { + config.User = user + return nil + } +} + +// WithWindowsContainers adds Windows containers to a security policy config. +func WithWindowsContainers(containers []WindowsContainerConfig) PolicyConfigOpt { + return func(config *PolicyConfig) error { + config.WindowsContainers = append(config.WindowsContainers, containers...) + return nil + } +} + func WithAllowUnencryptedScratch(allow bool) PolicyConfigOpt { return func(config *PolicyConfig) error { config.AllowUnencryptedScratch = allow diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index 5bf2eb9b5c..358b7746bd 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -58,15 +58,16 @@ const ( // PolicyConfig contains toml or JSON config for security policy. type PolicyConfig struct { - AllowAll bool `json:"allow_all" toml:"allow_all"` - Containers []ContainerConfig `json:"containers" toml:"container"` - ExternalProcesses []ExternalProcessConfig `json:"external_processes" toml:"external_process"` - Fragments []FragmentConfig `json:"fragments" toml:"fragment"` - AllowPropertiesAccess bool `json:"allow_properties_access" toml:"allow_properties_access"` - AllowDumpStacks bool `json:"allow_dump_stacks" toml:"allow_dump_stacks"` - AllowRuntimeLogging bool `json:"allow_runtime_logging" toml:"allow_runtime_logging"` - AllowHostNetwork bool `json:"allow_hostnetwork" toml:"allow_hostnetwork"` - AllowEnvironmentVariableDropping bool `json:"allow_environment_variable_dropping" toml:"allow_environment_variable_dropping"` + AllowAll bool `json:"allow_all" toml:"allow_all"` + Containers []ContainerConfig `json:"containers" toml:"container"` + WindowsContainers []WindowsContainerConfig `json:"windows_containers" toml:"windows_container"` + ExternalProcesses []ExternalProcessConfig `json:"external_processes" toml:"external_process"` + Fragments []FragmentConfig `json:"fragments" toml:"fragment"` + AllowPropertiesAccess bool `json:"allow_properties_access" toml:"allow_properties_access"` + AllowDumpStacks bool `json:"allow_dump_stacks" toml:"allow_dump_stacks"` + AllowRuntimeLogging bool `json:"allow_runtime_logging" toml:"allow_runtime_logging"` + AllowHostNetwork bool `json:"allow_hostnetwork" toml:"allow_hostnetwork"` + AllowEnvironmentVariableDropping bool `json:"allow_environment_variable_dropping" toml:"allow_environment_variable_dropping"` // AllowUnencryptedScratch is a global policy configuration that allows // all containers within a pod to be run without scratch encryption. AllowUnencryptedScratch bool `json:"allow_unencrypted_scratch" toml:"allow_unencrypted_scratch"` @@ -192,6 +193,22 @@ type ContainerConfig struct { SeccompProfilePath string `json:"seccomp_profile_path" toml:"seccomp_profile_path"` } +// WindowsContainerConfig contains TOML or JSON configuration for a Windows +// container described in a security policy. Like ContainerConfig, ImageName is +// the input; the tooling computes the verified Block CIM layer digests and the +// merged CIM digest from the image. +type WindowsContainerConfig struct { + ImageName string `json:"image_name" toml:"image_name"` + Auth AuthConfig `json:"auth" toml:"auth"` + Command []string `json:"command" toml:"command"` + EnvRules []EnvRuleConfig `json:"env_rules" toml:"env_rule"` + WorkingDir string `json:"working_dir" toml:"working_dir"` + ExecProcesses []WindowsExecProcessConfig `json:"exec_processes" toml:"exec_process"` + Signals []guestrequest.SignalValueWCOW `json:"signals" toml:"signals"` + AllowStdioAccess bool `json:"allow_stdio_access" toml:"allow_stdio_access"` + User string `json:"user" toml:"user"` +} + // MountConfig contains toml or JSON config for mount security policy // constraint description. type MountConfig struct { @@ -250,13 +267,6 @@ func NewEnvVarRules(envVars []string, required bool) []EnvRuleConfig { return rules } -// NewOpenDoorPolicy creates a new SecurityPolicy with AllowAll set to `true` -func NewOpenDoorPolicy() *SecurityPolicy { - return &SecurityPolicy{ - AllowAll: true, - } -} - // NewSecurityPolicyDigest decodes base64 encoded policy string, computes // and returns sha256 digest func NewSecurityPolicyDigest(base64policy string) ([]byte, error) { @@ -405,18 +415,32 @@ func CreateContainerPolicy( }, nil } -// NewSecurityPolicy creates a new SecurityPolicy from the provided values. -func NewSecurityPolicy(allowAll bool, containers []*Container) *SecurityPolicy { - containersMap := map[string]Container{} - for i, c := range containers { - containersMap[strconv.Itoa(i)] = *c - } - return &SecurityPolicy{ - AllowAll: allowAll, - Containers: Containers{ - Elements: containersMap, - }, +// CreateWindowsContainerPolicy creates a Windows container policy instance from +// the provided constraints or returns an error if validation fails. +func CreateWindowsContainerPolicy( + command, layers, mountedCim []string, + envRules []EnvRuleConfig, + workingDir string, + execProcesses []WindowsExecProcessConfig, + signals []guestrequest.SignalValueWCOW, + allowStdioAccess bool, + user string, +) (*WindowsContainer, error) { + if err := validateEnvRules(envRules); err != nil { + return nil, err } + + return &WindowsContainer{ + Command: newCommandArgs(command), + Layers: newLayers(layers), + MountedCim: mountedCim, + EnvRules: newEnvRules(envRules), + WorkingDir: workingDir, + ExecProcesses: execProcesses, + Signals: signals, + AllowStdioAccess: allowStdioAccess, + User: user, + }, nil } func validateEnvRules(rules []EnvRuleConfig) error { diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index dcaaae8eab..68117d9da0 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -42,6 +42,7 @@ type securityPolicyFragment struct { Namespace string SVN string Containers []*securityPolicyContainer + WindowsContainers []*securityPolicyWindowsContainer ExternalProcesses []*externalProcess Fragments []*fragment } @@ -145,6 +146,27 @@ func newSecurityPolicyFragment( }, nil } +func newWindowsSecurityPolicyFragment( + namespace string, + svn string, + containers []*WindowsContainer, + externalProcesses []ExternalProcessConfig, + fragments []FragmentConfig, +) (*securityPolicyFragment, error) { + containersInternal, err := windowsContainersToInternal(containers) + if err != nil { + return nil, err + } + + return &securityPolicyFragment{ + Namespace: namespace, + SVN: svn, + WindowsContainers: containersInternal, + ExternalProcesses: externalProcessToInternal(externalProcesses), + Fragments: fragmentsToInternal(fragments), + }, nil +} + // Internal version of Container type securityPolicyContainer struct { // The command that we will allow the container to execute @@ -329,6 +351,7 @@ func (c *WindowsContainer) toInternal() (*securityPolicyWindowsContainer, error) Command: command, EnvRules: envRules, Layers: layers, + MountedCim: c.MountedCim, WorkingDir: c.WorkingDir, ExecProcesses: execProcesses, Signals: c.Signals, diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index 4862d33be0..224a873011 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -16,18 +16,42 @@ import ( ) const ( - jsonMarshaller = "json" regoMarshaller = "rego" + // jsonMarshaller is retained only so callers that still request it get an + // actionable error: JSON policy output has been removed in favor of rego. + jsonMarshaller = "json" ) var ( registeredMarshallers = map[string]OSAwareMarshalFunc{} - defaultMarshaller = jsonMarshaller + defaultMarshaller = regoMarshaller ) func init() { - registeredMarshallers[jsonMarshaller] = marshalJSON registeredMarshallers[regoMarshaller] = osAwareMarshalRego + registeredMarshallers[jsonMarshaller] = rejectJSONMarshaller +} + +// rejectJSONMarshaller keeps "json" a recognized marshaller so callers that +// still request it get a clear message pointing at rego instead of a generic +// "unknown marshaller" error. +func rejectJSONMarshaller( + _ bool, + _ []*Container, + _ []*WindowsContainer, + _ string, + _ []ExternalProcessConfig, + _ []FragmentConfig, + _ bool, + _ bool, + _ bool, + _ bool, + _ bool, + _ bool, + _ bool, + _ bool, +) (string, error) { + return "", fmt.Errorf("JSON policy output is no longer supported; use the %q marshaller", regoMarshaller) } //go:embed policy.rego @@ -164,41 +188,6 @@ func marshalWindowsRego( return policy.marshalWindowsRego(), nil } -func marshalJSON( - allowAll bool, - containers []*Container, - windowsContainers []*WindowsContainer, - osType string, - _ []ExternalProcessConfig, - _ []FragmentConfig, - _ bool, - _ bool, - _ bool, - _ bool, - _ bool, - _ bool, - _ bool, - _ bool, -) (string, error) { - var policy *SecurityPolicy - if allowAll { - if len(containers) > 0 { - return "", ErrInvalidOpenDoorPolicy - } - - policy = NewOpenDoorPolicy() - } else { - policy = NewSecurityPolicy(allowAll, containers) - } - - policyCode, err := json.Marshal(policy) - if err != nil { - return "", err - } - - return string(policyCode), nil -} - func marshalRego( allowAll bool, containers []*Container, @@ -255,6 +244,21 @@ func MarshalFragment( return fragment.marshalRego(), nil } +// MarshalWindowsFragment encodes a Rego policy fragment for Windows containers. +func MarshalWindowsFragment( + namespace string, + svn string, + containers []*WindowsContainer, + externalProcesses []ExternalProcessConfig, + fragments []FragmentConfig) (string, error) { + fragment, err := newWindowsSecurityPolicyFragment(namespace, svn, containers, externalProcesses, fragments) + if err != nil { + return "", err + } + + return fragment.marshalRego(), nil +} + func MarshalPolicy( marshaller string, allowAll bool, @@ -296,6 +300,52 @@ func MarshalPolicy( } } +// MarshalWindowsPolicy encodes a security policy for Windows containers. +func MarshalWindowsPolicy( + marshaller string, + allowAll bool, + containers []*WindowsContainer, + externalProcesses []ExternalProcessConfig, + fragments []FragmentConfig, + allowPropertiesAccess bool, + allowDumpStacks bool, + allowRuntimeLogging bool, + allowHostNetwork bool, + allowEnvironmentVariableDropping bool, + allowUnencryptedScratch bool, + allowCapabilitiesDropping bool, + allowLogProviderDropping bool, +) (string, error) { + if marshaller == "" { + marshaller = regoMarshaller + } + if marshaller != regoMarshaller { + return "", fmt.Errorf("marshaller %q is not supported for Windows policies", marshaller) + } + + marshal, ok := registeredMarshallers[marshaller] + if !ok { + return "", fmt.Errorf("unknown marshaller: %q", marshaller) + } + + return marshal( + allowAll, + nil, + containers, + "windows", + externalProcesses, + fragments, + allowPropertiesAccess, + allowDumpStacks, + allowRuntimeLogging, + allowHostNetwork, + allowEnvironmentVariableDropping, + allowUnencryptedScratch, + allowCapabilitiesDropping, + allowLogProviderDropping, + ) +} + // Custom JSON marshalling to add `length` field that matches the number of // elements present in the `elements` field. @@ -362,10 +412,28 @@ var indentUsing string = " " type stringArray []string type signalArray []syscall.Signal +// regoString returns s as a JSON-compatible Rego double-quoted string literal, +// escaping backslashes and quotes (required for Windows paths and commands). +func regoString(s string) string { + // json.Marshal of a string never returns an error. + encoded, _ := json.Marshal(s) + return string(encoded) +} + +// mustMarshalJSON encodes v as JSON for embedding in a Rego policy. Encoding via +// the JSON marshaller guarantees every string value is safely quoted/escaped. +func mustMarshalJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(fmt.Errorf("failed to marshal Rego object to JSON: %w", err)) + } + return string(b) +} + func (array stringArray) marshalRego() string { values := make([]string, len(array)) for i, value := range array { - values[i] = fmt.Sprintf(`"%s"`, value) + values[i] = regoString(value) } return fmt.Sprintf("[%s]", strings.Join(values, ",")) @@ -390,11 +458,23 @@ func writeCommand(builder *strings.Builder, command []string, indent string) { } func (e EnvRuleConfig) marshalRego() string { + var v any if e.UseNameValue { - return fmt.Sprintf("{\"name\": `%s`, \"name_strategy\": \"%s\", \"value\": `%s`, \"value_strategy\": \"%s\", \"required\": %v}", e.Name, e.NameStrategy, e.Value, e.ValueStrategy, e.Required) + v = struct { + Name string `json:"name"` + NameStrategy string `json:"name_strategy"` + Value string `json:"value"` + ValueStrategy string `json:"value_strategy"` + Required bool `json:"required"` + }{e.Name, string(e.NameStrategy), e.Value, string(e.ValueStrategy), e.Required} } else { - return fmt.Sprintf("{\"pattern\": `%s`, \"strategy\": \"%s\", \"required\": %v}", e.Rule, e.Strategy, e.Required) + v = struct { + Pattern string `json:"pattern"` + Strategy string `json:"strategy"` + Required bool `json:"required"` + }{e.Rule, string(e.Strategy), e.Required} } + return mustMarshalJSON(v) } type envRuleArray []EnvRuleConfig @@ -436,7 +516,12 @@ func writeCapabilities(builder *strings.Builder, capabilities *capabilitiesInter func (m mountInternal) marshalRego() string { options := stringArray(m.Options).marshalRego() - return fmt.Sprintf(`{"destination": "%s", "options": %s, "source": "%s", "type": "%s"}`, m.Destination, options, m.Source, m.Type) + return mustMarshalJSON(struct { + Destination string `json:"destination"` + Options json.RawMessage `json:"options"` + Source string `json:"source"` + Type string `json:"type"` + }{m.Destination, json.RawMessage(options), m.Source, m.Type}) } func writeMounts(builder *strings.Builder, mounts []mountInternal, indent string) { @@ -459,7 +544,7 @@ func writeWindowsSignals(builder *strings.Builder, signals []guestrequest.Signal } func writeWindowsUser(builder *strings.Builder, user string, indent string) { - writeLine(builder, `%s"user": "%s",`, indent, user) + writeLine(builder, `%s"user": %s,`, indent, regoString(user)) } func (p windowsContainerExecProcess) marshalRego() string { @@ -470,7 +555,10 @@ func (p windowsContainerExecProcess) marshalRego() string { signalsArray[i] = string(s) } signals := stringArray(signalsArray).marshalRego() - return fmt.Sprintf(`{"command": %s, "signals": %s}`, command, signals) + return mustMarshalJSON(struct { + Command json.RawMessage `json:"command"` + Signals json.RawMessage `json:"signals"` + }{json.RawMessage(command), json.RawMessage(signals)}) } func writeWindowsExecProcesses(builder *strings.Builder, execProcesses []windowsContainerExecProcess, indent string) { @@ -490,7 +578,7 @@ func writeWindowsContainer(builder *strings.Builder, container *securityPolicyWi writeWindowsExecProcesses(builder, container.ExecProcesses, indent+indentUsing) writeWindowsSignals(builder, container.Signals, indent+indentUsing) writeWindowsUser(builder, container.User, indent+indentUsing) - writeLine(builder, `%s"working_dir": "%s",`, indent+indentUsing, container.WorkingDir) + writeLine(builder, `%s"working_dir": %s,`, indent+indentUsing, regoString(container.WorkingDir)) writeLine(builder, `%s"allow_stdio_access": %t,`, indent+indentUsing, container.AllowStdioAccess) writeLine(builder, "%s},", indent) } @@ -499,7 +587,10 @@ func (p containerExecProcess) marshalRego() string { command := stringArray(p.Command).marshalRego() signals := signalArray(p.Signals).marshalRego() - return fmt.Sprintf(`{"command": %s, "signals": %s}`, command, signals) + return mustMarshalJSON(struct { + Command json.RawMessage `json:"command"` + Signals json.RawMessage `json:"signals"` + }{json.RawMessage(command), json.RawMessage(signals)}) } func writeExecProcesses(builder *strings.Builder, execProcesses []containerExecProcess, indent string) { @@ -516,7 +607,10 @@ func writeSignals(builder *strings.Builder, signals []syscall.Signal, indent str } func (n IDNameConfig) marshalRego() string { - return fmt.Sprintf("{\"pattern\": `%s`, \"strategy\": \"%s\"}", n.Rule, n.Strategy) + return mustMarshalJSON(struct { + Pattern string `json:"pattern"` + Strategy string `json:"strategy"` + }{n.Rule, string(n.Strategy)}) } type idConfigArray []IDNameConfig @@ -535,7 +629,7 @@ func writeUser(builder *strings.Builder, user UserConfig, indent string) { writeLine(builder, `%s"user": {`, indent) writeLine(builder, `%s"user_idname": %s,`, indent+indentUsing, user.UserIDName.marshalRego()) writeLine(builder, `%s"group_idnames": %s,`, indent+indentUsing, groupIDNames) - writeLine(builder, `%s"umask": "%s"`, indent+indentUsing, user.Umask) + writeLine(builder, `%s"umask": %s`, indent+indentUsing, regoString(user.Umask)) writeLine(builder, `%s},`, indent) } @@ -549,9 +643,9 @@ func writeContainer(builder *strings.Builder, container *securityPolicyContainer writeSignals(builder, container.Signals, indent+indentUsing) writeUser(builder, container.User, indent+indentUsing) writeCapabilities(builder, container.Capabilities, indent+indentUsing) - writeLine(builder, `%s"seccomp_profile_sha256": "%s",`, indent+indentUsing, container.SeccompProfileSHA256) + writeLine(builder, `%s"seccomp_profile_sha256": %s,`, indent+indentUsing, regoString(container.SeccompProfileSHA256)) writeLine(builder, `%s"allow_elevated": %t,`, indent+indentUsing, container.AllowElevated) - writeLine(builder, `%s"working_dir": "%s",`, indent+indentUsing, container.WorkingDir) + writeLine(builder, `%s"working_dir": %s,`, indent+indentUsing, regoString(container.WorkingDir)) writeLine(builder, `%s"allow_stdio_access": %t,`, indent+indentUsing, container.AllowStdioAccess) writeLine(builder, `%s"no_new_privileges": %t,`, indent+indentUsing, container.NoNewPrivileges) writeLine(builder, "%s},", indent) @@ -572,7 +666,12 @@ func addContainers(builder *strings.Builder, containers []*securityPolicyContain func (p externalProcess) marshalRego() string { command := stringArray(p.command).marshalRego() envRules := envRuleArray(p.envRules).marshalRego() - return fmt.Sprintf(`{"command": %s, "env_rules": %s, "working_dir": "%s", "allow_stdio_access": %t}`, command, envRules, p.workingDir, p.allowStdioAccess) + return mustMarshalJSON(struct { + Command json.RawMessage `json:"command"` + EnvRules json.RawMessage `json:"env_rules"` + WorkingDir string `json:"working_dir"` + AllowStdioAccess bool `json:"allow_stdio_access"` + }{json.RawMessage(command), json.RawMessage(envRules), p.workingDir, p.allowStdioAccess}) } func addExternalProcesses(builder *strings.Builder, processes []*externalProcess) { @@ -591,18 +690,26 @@ func addExternalProcesses(builder *strings.Builder, processes []*externalProcess func (f fragment) marshalRego() string { includes := stringArray(f.includes).marshalRego() - - if len(f.parameters) == 0 { - return fmt.Sprintf(`{"issuer": "%s", "feed": "%s", "minimum_svn": "%s", "includes": %s}`, - f.issuer, f.feed, f.minimumSVN, includes) - } - - paramsJSON, err := json.Marshal(f.parameters) - if err != nil { - panic(fmt.Errorf("failed to marshal fragment parameters object to JSON: %w", err)) + obj := struct { + Issuer string `json:"issuer"` + Feed string `json:"feed"` + MinimumSVN string `json:"minimum_svn"` + Includes json.RawMessage `json:"includes"` + Parameters json.RawMessage `json:"parameters,omitempty"` + }{ + Issuer: f.issuer, + Feed: f.feed, + MinimumSVN: f.minimumSVN, + Includes: json.RawMessage(includes), + } + if len(f.parameters) > 0 { + paramsJSON, err := json.Marshal(f.parameters) + if err != nil { + panic(fmt.Errorf("failed to marshal fragment parameters object to JSON: %w", err)) + } + obj.Parameters = json.RawMessage(paramsJSON) } - return fmt.Sprintf(`{"issuer": "%s", "feed": "%s", "minimum_svn": "%s", "includes": %s, "parameters": %s}`, - f.issuer, f.feed, f.minimumSVN, includes, string(paramsJSON)) + return mustMarshalJSON(obj) } func addFragments(builder *strings.Builder, fragments []*fragment) { @@ -642,6 +749,7 @@ func (p securityPolicyFragment) marshalRego() string { builder := new(strings.Builder) addFragments(builder, p.Fragments) addContainers(builder, p.Containers) + addWindowsContainers(builder, p.WindowsContainers) addExternalProcesses(builder, p.ExternalProcesses) return fmt.Sprintf("package %s\n\nsvn := \"%s\"\nframework_version := \"%s\"\n\n%s", p.Namespace, p.SVN, frameworkVersion, builder.String()) } diff --git a/pkg/securitypolicy/windows_tooling_test.go b/pkg/securitypolicy/windows_tooling_test.go new file mode 100644 index 0000000000..f9d5fd740f --- /dev/null +++ b/pkg/securitypolicy/windows_tooling_test.go @@ -0,0 +1,145 @@ +package securitypolicy + +import ( + "strings" + "testing" +) + +func TestCreateWindowsContainerPolicy(t *testing.T) { + container, err := CreateWindowsContainerPolicy( + []string{"cmd.exe", "/c", "echo test"}, + []string{"layer-1", "layer-2"}, + []string{"merged-cim"}, + []EnvRuleConfig{{Strategy: EnvVarRuleString, Rule: "PATH=C:\\Windows", Required: true}}, + "C:\\", + nil, + nil, + true, + "ContainerUser", + ) + if err != nil { + t.Fatal(err) + } + + if got := container.Command.Elements["0"]; got != "cmd.exe" { + t.Fatalf("unexpected command: %q", got) + } + if got := container.Layers.Elements["1"]; got != "layer-2" { + t.Fatalf("unexpected layer: %q", got) + } + if got := container.MountedCim; len(got) != 1 || got[0] != "merged-cim" { + t.Fatalf("unexpected mounted CIM: %v", got) + } + if container.WorkingDir != "C:\\" || container.User != "ContainerUser" || !container.AllowStdioAccess { + t.Fatalf("unexpected Windows container fields: %+v", container) + } +} + +func TestCreateWindowsContainerPolicyRejectsInvalidEnvRegex(t *testing.T) { + _, err := CreateWindowsContainerPolicy(nil, nil, nil, []EnvRuleConfig{{ + Strategy: EnvVarRuleRegex, + Rule: "[", + }}, "", nil, nil, false, "") + if err == nil { + t.Fatal("expected invalid environment regex to fail") + } +} + +func TestMarshalWindowsPolicy(t *testing.T) { + container, err := CreateWindowsContainerPolicy( + []string{"cmd.exe"}, + []string{"layer-hash"}, + []string{"merged-cim-hash"}, + nil, + "C:\\", + nil, + nil, + false, + "", + ) + if err != nil { + t.Fatal(err) + } + + policy, err := MarshalWindowsPolicy("rego", false, []*WindowsContainer{container}, nil, nil, false, false, false, false, false, false, false, false) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"layer-hash", "merged-cim-hash", "cmd.exe"} { + if !strings.Contains(policy, expected) { + t.Errorf("policy does not contain %q", expected) + } + } +} + +func TestMarshalWindowsPolicyRejectsJSON(t *testing.T) { + _, err := MarshalWindowsPolicy("json", false, nil, nil, nil, false, false, false, false, false, false, false, false) + if err == nil { + t.Fatal("expected JSON marshalling to be rejected for Windows policies") + } +} + +func TestMarshalPolicyRejectsJSON(t *testing.T) { + _, err := MarshalPolicy("json", false, nil, nil, nil, false, false, false, false, false, false, false, false) + if err == nil { + t.Fatal("expected JSON marshalling to be rejected") + } +} + +func TestMarshalWindowsPolicyEscapesBackslashes(t *testing.T) { + container, err := CreateWindowsContainerPolicy( + []string{"C:\\app\\run.exe"}, + []string{"layer-hash"}, + []string{"merged-cim-hash"}, + nil, + "C:\\", + nil, + nil, + false, + "NT AUTHORITY\\SYSTEM", + ) + if err != nil { + t.Fatal(err) + } + + policy, err := MarshalWindowsPolicy("rego", false, []*WindowsContainer{container}, nil, nil, false, false, false, false, false, false, false, false) + if err != nil { + t.Fatal(err) + } + // An unescaped backslash before a quote (`"C:\"`) would break Rego parsing. + if strings.Contains(policy, `"C:\"`) { + t.Fatalf("working_dir backslash not escaped:\n%s", policy) + } + for _, want := range []string{`"working_dir": "C:\\"`, `"C:\\app\\run.exe"`, `"user": "NT AUTHORITY\\SYSTEM"`} { + if !strings.Contains(policy, want) { + t.Errorf("policy missing %q", want) + } + } +} + +func TestMarshalWindowsFragment(t *testing.T) { + container, err := CreateWindowsContainerPolicy( + []string{"cmd.exe"}, + []string{"layer-hash"}, + []string{"merged-cim-hash"}, + nil, + "C:\\", + nil, + nil, + false, + "", + ) + if err != nil { + t.Fatal(err) + } + + fragment, err := MarshalWindowsFragment("contoso.example", "1", []*WindowsContainer{container}, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"package contoso.example", "svn := \"1\"", "layer-hash", "merged-cim-hash", "cmd.exe"} { + if !strings.Contains(fragment, expected) { + t.Errorf("fragment does not contain %q", expected) + } + } +} diff --git a/test/pkg/securitypolicy/policy.go b/test/pkg/securitypolicy/policy.go index 742ef97ab8..32bff9fc68 100644 --- a/test/pkg/securitypolicy/policy.go +++ b/test/pkg/securitypolicy/policy.go @@ -1,6 +1,7 @@ package securitypolicy import ( + "context" "encoding/base64" "strings" "testing" @@ -74,6 +75,62 @@ func PolicyWithOpts(tb testing.TB, policyType string, pOpts ...securitypolicy.Po } +func PolicyFromWindowsContainerWithOpts( + tb testing.TB, + policyType string, + cOpts []securitypolicy.WindowsContainerConfigOpt, + pOpts []securitypolicy.PolicyConfigOpt, +) string { + tb.Helper() + containerConfig := securitypolicy.WindowsContainerConfig{} + for _, option := range cOpts { + if err := option(&containerConfig); err != nil { + tb.Fatal(err) + } + } + + policyOpts := []securitypolicy.PolicyConfigOpt{ + securitypolicy.WithWindowsContainers([]securitypolicy.WindowsContainerConfig{ + containerConfig, + }), + } + policyOpts = append(policyOpts, pOpts...) + + return WindowsPolicyWithOpts(tb, policyType, policyOpts...) +} + +func WindowsPolicyWithOpts(tb testing.TB, policyType string, pOpts ...securitypolicy.PolicyConfigOpt) string { + tb.Helper() + config, err := securitypolicy.NewPolicyConfig(pOpts...) + if err != nil { + tb.Fatal(err) + } + + policyContainers, err := helpers.PolicyWindowsContainersFromConfigs(context.Background(), config.WindowsContainers) + if err != nil { + tb.Fatal(err) + } + policyString, err := securitypolicy.MarshalWindowsPolicy( + policyType, + config.AllowAll, + policyContainers, + config.ExternalProcesses, + config.Fragments, + config.AllowPropertiesAccess, + config.AllowDumpStacks, + config.AllowRuntimeLogging, + config.AllowHostNetwork, + config.AllowEnvironmentVariableDropping, + config.AllowUnencryptedScratch, + config.AllowCapabilityDropping, + config.AllowLogProviderDropping, + ) + if err != nil { + tb.Fatal(err) + } + return base64.StdEncoding.EncodeToString([]byte(policyString)) +} + func AssertErrorContains(t *testing.T, err error, expected string) bool { t.Helper() if err == nil {