diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index a40c2d72ea..8173dfaef7 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -64,10 +64,9 @@ func (b *Bridge) createContainer(req *request) (err error) { return errors.Wrap(err, "failed to unmarshal createContainer") } - // containerConfig can be of type uvnConfig or hcsschema.HostedSystem or guestresource.CWCOWHostedSystem + // containerConfig can be of type uvmConfig or guestresource.CWCOWHostedSystem var ( uvmConfig prot.UvmConfig - hostedSystemConfig hcsschema.HostedSystem cwcowHostedSystemConfig guestresource.CWCOWHostedSystem ) if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &uvmConfig); err == nil && @@ -75,11 +74,6 @@ func (b *Bridge) createContainer(req *request) (err error) { systemType := uvmConfig.SystemType timeZoneInformation := uvmConfig.TimeZoneInformation log.G(ctx).Tracef("createContainer: uvmConfig: {systemType: %v, timeZoneInformation: %v}}", systemType, timeZoneInformation) - } else if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &hostedSystemConfig); err == nil && - hostedSystemConfig.SchemaVersion != nil && hostedSystemConfig.Container != nil { - schemaVersion := hostedSystemConfig.SchemaVersion - container := hostedSystemConfig.Container - log.G(ctx).Tracef("rpcCreate: HostedSystemConfig: {schemaVersion: %v, container: %v}}", schemaVersion, container) } else if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &cwcowHostedSystemConfig); err == nil && cwcowHostedSystemConfig.Spec.Version != "" && cwcowHostedSystemConfig.CWCOWHostedSystem.Container != nil { cwcowHostedSystem := cwcowHostedSystemConfig.CWCOWHostedSystem @@ -597,7 +591,6 @@ func (b *Bridge) modifyServiceSettings(req *request) (err error) { defer span.End() defer func() { oc.SetSpanStatus(span, err) }() - // Todo: Add policy enforcement for modifying service settings modifyRequest, err := unmarshalModifyServiceSettings(req) if err != nil { return fmt.Errorf("failed to unmarshal modifyServiceSettings request: %w", err) @@ -612,28 +605,54 @@ func (b *Bridge) modifyServiceSettings(req *request) (err error) { switch settings.RPCType { case guestrequest.RPCModifyServiceSettings, guestrequest.RPCStartLogForwarding, guestrequest.RPCStopLogForwarding: log.G(req.ctx).Tracef("%v request received for LogForwardService, proceeding with policy enforcement for log sources", settings.RPCType) - // Enforce the policy for log sources in the request and update the settings with allowed log sources. - // For cwcow, the sidecar-GCS will verify the allowed log sources against policy and append the necessary GUIDs to the ones allowed. Rest are dropped. - // The Enforcer will have to unmarshal the log sources, enforce the policy and then marshal it back to a Base64 encoded JSON string which is what inbox GCS expects. - // It can query etw.GetDefaultLogSources to get the default log sources if the policy allows, and allow providers matching the default list during policy enforcement. - // This is because the log sources can be a combination of default and user specified log sources for which GUIDs need to be appended based on the policy enforcement. if settings.Settings != "" { - // - // allowedLogSources, err := b.hostState.securityOptions.PolicyEnforcer.EnforceLogForwardServiceSettingsPolicy(req.ctx, settings.LogSources) + // Decode the base64-encoded log sources config so we can + // enforce policy on the requested provider list. + logSources, err := etw.DecodeAndUnmarshalLogSources(settings.Settings) + if err != nil { + return fmt.Errorf("failed to decode log sources: %w", err) + } + + // Validate host-supplied (Name, GUID) pairs before + // name-based policy enforcement. + if err := validateLogProviders(logSources.LogConfig.Sources); err != nil { + return fmt.Errorf("log providers rejected: %w", err) + } + + // Collect every requested provider name and ask the + // enforcer to validate them as a batch. The enforcer's + // behaviour depends on allow_log_provider_dropping in the + // active policy: + // - false (default, fail-close): any disallowed provider + // causes the call to be denied. + // - true: disallowed providers are silently dropped and + // the kept subset is returned for forwarding. + var requestedNames []string + for _, source := range logSources.LogConfig.Sources { + for _, provider := range source.Providers { + requestedNames = append(requestedNames, provider.ProviderName) + } + } + + keptNames, err := b.hostState.securityOptions.PolicyEnforcer.EnforceLogProviderPolicy( + req.ctx, requestedNames) + if err != nil { + return fmt.Errorf("log providers denied by policy: %w", err) + } - // For now, we are skipping the policy enforcement and allowing all log sources as the policy enforcer implementation is in progress. We will add the enforcement back once it's implemented. - allowedLogSources := settings.Settings // This is Base64 encoded JSON string of log sources - log.G(req.ctx).Tracef("Allowed log sources after policy enforcement: %v", allowedLogSources) + filtered := filterLogSourcesToAllowed(req.ctx, logSources, keptNames) - // Update the allowed log sources in the settings. This will be forwarded to inbox GCS which expects the log sources in a JSON string format with GUIDs for providers included. - allowedLogSources, err := etw.UpdateLogSources(allowedLogSources, false, true) + // Apply GUID resolution (and any other inbox-GCS prep) + // against the policy-trimmed payload and hand off to + // inbox GCS. + allowedLogSources, err := etw.UpdateLogSourcesFromInfo(filtered, false, true) if err != nil { return fmt.Errorf("failed to update log sources: %w", err) } settings.Settings = allowedLogSources } default: - log.G(req.ctx).Warningf("modifyServiceSettings for LogForwardService with RPCType: %v, skipping policy enforcement", settings.RPCType) + return fmt.Errorf("modifyServiceSettings for LogForwardService: unsupported RPCType %q", settings.RPCType) } modifyRequest.Settings = settings buf, err := json.Marshal(modifyRequest) @@ -650,12 +669,96 @@ func (b *Bridge) modifyServiceSettings(req *request) (err error) { log.G(req.ctx).Warningf("modifyServiceSettings for LogForwardService with empty settings, skipping policy enforcement") } default: - log.G(req.ctx).Warningf("modifyServiceSettings with PropertyType: %v, skipping policy enforcement", modifyRequest.PropertyType) + return fmt.Errorf("modifyServiceSettings: unsupported PropertyType %q", modifyRequest.PropertyType) } b.forwardRequestToGcs(req) return nil } +// validateLogProviders validates host-supplied log providers before they +// reach the name-based policy enforcer. +// +// CWCOW policy approves provider names, but inbox GCS subscribes by GUID. If +// the host could send {Name: "allowed", GUID: ""} the name-based +// enforcer would approve and the disallowed GUID would still be forwarded +// (resolveGUIDsWithLookup keeps any GUID the host set). To close that bypass +// the sidecar rejects, before enforcement, any entry whose (Name, GUID) pair +// is not verifiable against the well-known ETW map: +// +// - Name == "": rejected. Policy is name-based; a GUID-only entry has +// nothing for the enforcer to evaluate. +// - Name + GUID where Name is not in the well-known map: rejected. We have +// no ground truth to compare the GUID against, so we cannot verify the +// host's claim. Name-only is still accepted for downstream resolution to +// stay best-effort. +// - Name + GUID where the GUID disagrees with the well-known lookup for +// Name: rejected. +// +// Name-only entries are passed through unchanged; the sidecar fills in the +// canonical GUID after enforcement via etw.UpdateLogSourcesFromInfo. +func validateLogProviders(sources []etw.Source) error { + for _, src := range sources { + for _, p := range src.Providers { + if p.ProviderName == "" { + return fmt.Errorf("provider with no name is not allowed (GUID %q)", p.ProviderGUID) + } + if p.ProviderGUID == "" { + continue + } + well := etw.GetProviderGUIDFromName(p.ProviderName) + if well == "" { + return fmt.Errorf("provider %q: name not in well-known ETW map; cannot verify supplied GUID %q", p.ProviderName, p.ProviderGUID) + } + suppliedTrimmed := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(p.ProviderGUID), "{"), "}") + supplied, err := guid.FromString(suppliedTrimmed) + if err != nil { + return fmt.Errorf("provider %q: invalid GUID %q: %w", p.ProviderName, p.ProviderGUID, err) + } + if !strings.EqualFold(supplied.String(), well) { + return fmt.Errorf("provider %q: supplied GUID %q does not match well-known GUID %q", p.ProviderName, p.ProviderGUID, well) + } + } + } + return nil +} + +func filterLogSourcesToAllowed(ctx context.Context, sources etw.LogSourcesInfo, keptNames []string) etw.LogSourcesInfo { + keepSet := make(map[string]struct{}, len(keptNames)) + for _, name := range keptNames { + keepSet[name] = struct{}{} + } + + var requestedNames []string + dropped := make([]string, 0) + seenDropped := make(map[string]struct{}) + for i := range sources.LogConfig.Sources { + src := &sources.LogConfig.Sources[i] + filtered := make([]etw.EtwProvider, 0, len(src.Providers)) + for _, p := range src.Providers { + requestedNames = append(requestedNames, p.ProviderName) + if _, ok := keepSet[p.ProviderName]; ok { + filtered = append(filtered, p) + continue + } + if _, dup := seenDropped[p.ProviderName]; !dup { + seenDropped[p.ProviderName] = struct{}{} + dropped = append(dropped, p.ProviderName) + } + } + src.Providers = filtered + } + + if len(dropped) > 0 { + log.G(ctx).WithFields(map[string]interface{}{ + "requested": requestedNames, + "kept": keptNames, + "dropped": dropped, + }).Warn("log providers trimmed by policy (allow_log_provider_dropping)") + } + + return sources +} + func volumeGUIDFromLayerPath(path string) (string, bool) { if p, ok := strings.CutPrefix(path, `\\?\Volume{`); ok { if q, ok := strings.CutSuffix(p, `}\Files`); ok { diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 6de3a0a605..1460063533 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -5,6 +5,7 @@ package bridge import ( "context" + "encoding/base64" "encoding/json" "io" "testing" @@ -12,9 +13,12 @@ import ( "github.com/Microsoft/go-winio/pkg/guid" "github.com/Microsoft/hcsshim/internal/gcs/prot" + "github.com/Microsoft/hcsshim/internal/log" "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" "github.com/Microsoft/hcsshim/internal/protocol/guestresource" + "github.com/Microsoft/hcsshim/internal/vm/vmutils/etw" "github.com/Microsoft/hcsshim/pkg/securitypolicy" + "github.com/sirupsen/logrus" ) // buildModifySettingsRequest creates a serialized ModifySettings request message @@ -327,3 +331,499 @@ func TestModifySettings_PolicyFragment_TypeAssertionFailure(t *testing.T) { t.Fatal("expected error for empty fragment, got nil") } } + +// buildLogForwardServiceRequest builds a serialized ServiceModificationRequest +// for the LogForwardService with the given provider names baked into a +// base64-encoded LogSourcesInfo payload. +func buildLogForwardServiceRequest(t *testing.T, providerNames ...string) []byte { + t.Helper() + + providers := make([]etw.EtwProvider, 0, len(providerNames)) + for _, name := range providerNames { + providers = append(providers, etw.EtwProvider{ProviderName: name}) + } + info := etw.LogSourcesInfo{ + LogConfig: etw.LogConfig{ + Sources: []etw.Source{{ + Type: "etw", + Providers: providers, + }}, + }, + } + infoBytes, err := json.Marshal(info) + if err != nil { + t.Fatalf("failed to marshal log sources: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(infoBytes) + + inner := &guestrequest.LogForwardServiceRPCRequest{ + RPCType: guestrequest.RPCModifyServiceSettings, + Settings: encoded, + } + req := prot.ServiceModificationRequest{ + RequestBase: prot.RequestBase{ + ContainerID: UVMContainerID, + ActivityID: guid.GUID{}, + }, + PropertyType: string(prot.LogForwardService), + Settings: inner, + } + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + return b +} + +// newModifyServiceSettingsRequest wraps the given LogForwardService payload +// in a bridge `request` ready for modifyServiceSettings. +func newModifyServiceSettingsRequest(payload []byte) *request { + return &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCModifyServiceSettings), + Size: uint32(len(payload)) + prot.HdrSize, + ID: 1, + }, + activityID: guid.GUID{}, + message: payload, + } +} + +// TestModifyServiceSettings_LogForward_PolicyAllow_ForwardsToGCS verifies that +// when every requested provider is allowed by policy, the call succeeds and +// the (possibly GUID-resolved) request is forwarded to inbox GCS. +func TestModifyServiceSettings_LogForward_PolicyAllow_ForwardsToGCS(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + // Use a provider that is in the known etw_map so UpdateLogSources's GUID + // resolution succeeds. + payload := buildLogForwardServiceRequest(t, "microsoft.windows.hyperv.compute") + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err != nil { + t.Fatalf("modifyServiceSettings with allowed provider returned error: %v", err) + } + + select { + case <-b.sendToGCSCh: + // Forwarded to GCS as expected. + case <-time.After(time.Second): + t.Fatal("timed out waiting for request to be forwarded to GCS") + } +} + +// TestModifyServiceSettings_LogForward_PolicyDeny_ReturnsErrorAndDoesNotForward +// verifies that when any requested provider is denied by policy, the call +// fails and the request is not forwarded to inbox GCS. +func TestModifyServiceSettings_LogForward_PolicyDeny_ReturnsErrorAndDoesNotForward(t *testing.T) { + b := newTestBridge(&securitypolicy.ClosedDoorSecurityPolicyEnforcer{}) + + payload := buildLogForwardServiceRequest(t, "microsoft.windows.hyperv.compute") + req := newModifyServiceSettingsRequest(payload) + + err := b.modifyServiceSettings(req) + if err == nil { + t.Fatal("expected modifyServiceSettings to fail under ClosedDoor enforcer") + } + + // The request must NOT have been forwarded to GCS. + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("denied request must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// droppingLogProviderEnforcer is a test stub that approves only the configured +// allow-list of provider names; any others are silently dropped from the +// returned subset. It mirrors the regoEnforcer's behaviour under +// allow_log_provider_dropping := true and never returns an error. +type droppingLogProviderEnforcer struct { + securitypolicy.OpenDoorSecurityPolicyEnforcer + allowed map[string]struct{} +} + +func (e *droppingLogProviderEnforcer) EnforceLogProviderPolicy(_ context.Context, providerNames []string) ([]string, error) { + kept := make([]string, 0, len(providerNames)) + for _, name := range providerNames { + if _, ok := e.allowed[name]; ok { + kept = append(kept, name) + } + } + return kept, nil +} + +// TestModifyServiceSettings_LogForward_PolicyDropping_TrimsForwardedPayload +// verifies the silent-drop path in the sidecar: when the enforcer returns a +// strict subset of the requested providers, the call succeeds and the payload +// forwarded to inbox GCS contains only the kept providers (not the original +// disallowed ones). +func TestModifyServiceSettings_LogForward_PolicyDropping_TrimsForwardedPayload(t *testing.T) { + kept := "microsoft.windows.hyperv.compute" + dropped := "some-bogus-provider" + enforcer := &droppingLogProviderEnforcer{ + allowed: map[string]struct{}{kept: {}}, + } + b := newTestBridge(enforcer) + + payload := buildLogForwardServiceRequest(t, kept, dropped) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err != nil { + t.Fatalf("modifyServiceSettings under dropping enforcer returned error: %v", err) + } + + var forwarded request + select { + case forwarded = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("timed out waiting for request to be forwarded to GCS") + } + + // Decode the forwarded request back into LogSourcesInfo and confirm the + // disallowed provider has been stripped while the allowed one survives. + var fwdReq prot.ServiceModificationRequest + fwdReq.Settings = &guestrequest.LogForwardServiceRPCRequest{} + if err := json.Unmarshal(forwarded.message, &fwdReq); err != nil { + t.Fatalf("failed to unmarshal forwarded request: %v", err) + } + innerSettings, ok := fwdReq.Settings.(*guestrequest.LogForwardServiceRPCRequest) + if !ok { + t.Fatalf("forwarded settings has unexpected type: %T", fwdReq.Settings) + } + logSources, err := etw.DecodeAndUnmarshalLogSources(innerSettings.Settings) + if err != nil { + t.Fatalf("failed to decode forwarded log sources: %v", err) + } + + var sawKept, sawDropped bool + for _, src := range logSources.LogConfig.Sources { + for _, p := range src.Providers { + if p.ProviderName == kept { + sawKept = true + } + if p.ProviderName == dropped { + sawDropped = true + } + } + } + if !sawKept { + t.Errorf("expected forwarded payload to contain kept provider %q", kept) + } + if sawDropped { + t.Errorf("expected dropped provider %q to be absent from forwarded payload", dropped) + } +} + +// captureHook is a tiny logrus hook that records every entry it sees. +// Used by TestModifyServiceSettings_LogForward_PolicyDropping_NoFalsePositive +// to assert the "log providers trimmed by policy" Warn is *not* emitted when +// the only reason kept and requested differ is set-deduplication. +type captureHook struct { + entries []*logrus.Entry +} + +func (h *captureHook) Levels() []logrus.Level { return logrus.AllLevels } +func (h *captureHook) Fire(e *logrus.Entry) error { + h.entries = append(h.entries, e) + return nil +} + +// TestModifyServiceSettings_LogForward_PolicyDropping_NoFalsePositive guards +// against a false-positive trim warning + needless re-marshal when the +// enforcer returns a deduplicated set. The rego implementation builds +// providers_to_keep via a stringSet (see getProvidersToKeep), so a request +// with duplicate provider names like [A, A] comes back as [A] even when +// nothing was actually dropped. Detection must be based on "some requested +// name is missing from keepSet", not len(kept) != len(requested). +func TestModifyServiceSettings_LogForward_PolicyDropping_NoFalsePositive(t *testing.T) { + name := "microsoft.windows.hyperv.compute" + enforcer := &droppingLogProviderEnforcer{ + allowed: map[string]struct{}{name: {}}, + } + b := newTestBridge(enforcer) + + // Two copies of the same allowed provider. dedup in the enforcer means + // kept=[name] while requested=[name, name]; the lengths differ but the + // set of requested names is fully covered, so this is NOT a trim. + payload := buildLogForwardServiceRequest(t, name, name) + req := newModifyServiceSettingsRequest(payload) + + // Scope the capture hook to a request-local logger (rather than mutating + // the global logrus) by injecting it into the request context. + logger := logrus.New() + logger.SetOutput(io.Discard) + hook := &captureHook{} + logger.AddHook(hook) + req.ctx, _ = log.WithContext(req.ctx, logrus.NewEntry(logger)) + + if err := b.modifyServiceSettings(req); err != nil { + t.Fatalf("modifyServiceSettings under dropping enforcer (dedup) returned error: %v", err) + } + + // Must forward to GCS. + select { + case <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("timed out waiting for request to be forwarded to GCS") + } + + // Must NOT have emitted the trim warning: nothing was actually dropped. + for _, e := range hook.entries { + if e.Level == logrus.WarnLevel && + e.Message == "log providers trimmed by policy (allow_log_provider_dropping)" { + t.Errorf("false-positive trim warning emitted on a dedup-only mismatch (kept=%v requested=%v dropped=%v)", + e.Data["kept"], e.Data["requested"], e.Data["dropped"]) + } + } +} + +// TestModifyServiceSettings_UnsupportedPropertyType_Denied verifies that a +// ModifyServiceSettings request whose PropertyType is not one the sidecar +// structurally understands is rejected and not forwarded to inbox GCS. +// +// An empty PropertyType is used because unmarshalModifyServiceSettings only +// validates non-empty PropertyType values, so this is the path that actually +// reaches the handler's outer switch default. +func TestModifyServiceSettings_UnsupportedPropertyType_Denied(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + r := prot.ServiceModificationRequest{ + RequestBase: prot.RequestBase{ + ContainerID: UVMContainerID, + ActivityID: guid.GUID{}, + }, + // PropertyType deliberately empty to exercise the handler's + // outer-switch default branch. + PropertyType: "", + } + payload, err := json.Marshal(r) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err == nil { + t.Fatal("expected modifyServiceSettings to fail for unsupported PropertyType") + } + + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("request with unsupported PropertyType must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// TestModifyServiceSettings_LogForward_UnsupportedRPCType_Denied verifies +// that a LogForwardService request carrying an RPCType the sidecar does not +// recognise is rejected and not forwarded to inbox GCS. +func TestModifyServiceSettings_LogForward_UnsupportedRPCType_Denied(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + inner := &guestrequest.LogForwardServiceRPCRequest{ + RPCType: guestrequest.RPCType("UnsupportedRPCType"), + } + r := prot.ServiceModificationRequest{ + RequestBase: prot.RequestBase{ + ContainerID: UVMContainerID, + ActivityID: guid.GUID{}, + }, + PropertyType: string(prot.LogForwardService), + Settings: inner, + } + payload, err := json.Marshal(r) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err == nil { + t.Fatal("expected modifyServiceSettings to fail for unsupported RPCType") + } + + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("request with unsupported RPCType must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// buildLogForwardServiceRequestWithProviders is the variant of +// buildLogForwardServiceRequest that lets each test set ProviderName and +// ProviderGUID independently, so the validateLogProviders tests can +// exercise mismatched and GUID-only payloads. +func buildLogForwardServiceRequestWithProviders(t *testing.T, providers []etw.EtwProvider) []byte { + t.Helper() + + info := etw.LogSourcesInfo{ + LogConfig: etw.LogConfig{ + Sources: []etw.Source{{ + Type: "etw", + Providers: providers, + }}, + }, + } + infoBytes, err := json.Marshal(info) + if err != nil { + t.Fatalf("failed to marshal log sources: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(infoBytes) + + inner := &guestrequest.LogForwardServiceRPCRequest{ + RPCType: guestrequest.RPCModifyServiceSettings, + Settings: encoded, + } + req := prot.ServiceModificationRequest{ + RequestBase: prot.RequestBase{ + ContainerID: UVMContainerID, + ActivityID: guid.GUID{}, + }, + PropertyType: string(prot.LogForwardService), + Settings: inner, + } + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + return b +} + +// TestModifyServiceSettings_LogForward_GUIDOnly_Denied verifies that a +// provider entry with ProviderName=="" (GUID-only) is rejected before +// reaching policy enforcement. CWCOW policy is name-based, so a GUID-only +// entry has nothing for the enforcer to evaluate; accepting it would let the +// host smuggle a disallowed GUID past name-based policy. +func TestModifyServiceSettings_LogForward_GUIDOnly_Denied(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + payload := buildLogForwardServiceRequestWithProviders(t, []etw.EtwProvider{ + {ProviderName: "", ProviderGUID: "80ce50de-d264-4581-950d-abadeee0d340"}, + }) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err == nil { + t.Fatal("expected modifyServiceSettings to reject GUID-only provider entry") + } + + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("rejected request must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// TestModifyServiceSettings_LogForward_NameGUIDMismatch_Denied verifies that +// a provider entry whose ProviderGUID disagrees with the well-known map +// lookup for ProviderName is rejected. Without this check a hostile host +// could pair an allowed Name with a disallowed GUID and bypass name-based +// policy because inbox GCS subscribes by GUID. +func TestModifyServiceSettings_LogForward_NameGUIDMismatch_Denied(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + // Name resolves to 80ce50de-d264-4581-950d-abadeee0d340 in the + // well-known map; deliberately supply an unrelated valid GUID. + payload := buildLogForwardServiceRequestWithProviders(t, []etw.EtwProvider{ + { + ProviderName: "microsoft.windows.hyperv.compute", + ProviderGUID: "11111111-2222-3333-4444-555555555555", + }, + }) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err == nil { + t.Fatal("expected modifyServiceSettings to reject Name/GUID mismatch") + } + + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("rejected request must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// TestModifyServiceSettings_LogForward_UnknownNameWithGUID_Denied verifies +// that a provider entry whose ProviderName is not in the well-known ETW map +// is rejected when paired with a ProviderGUID: the sidecar has no ground +// truth to verify the host's claim against. +func TestModifyServiceSettings_LogForward_UnknownNameWithGUID_Denied(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + payload := buildLogForwardServiceRequestWithProviders(t, []etw.EtwProvider{ + { + ProviderName: "unknown-provider", + ProviderGUID: "11111111-2222-3333-4444-555555555555", + }, + }) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err == nil { + t.Fatal("expected modifyServiceSettings to reject unknown Name + GUID") + } + + select { + case fwd := <-b.sendToGCSCh: + t.Fatalf("rejected request must not be forwarded to GCS: %+v", fwd) + default: + // Good. + } +} + +// TestModifyServiceSettings_LogForward_NameMatchingGUID_Allowed verifies the +// positive path of validateLogProviders: a provider entry where +// ProviderGUID matches the well-known lookup for ProviderName passes +// validation and is forwarded to inbox GCS. +func TestModifyServiceSettings_LogForward_NameMatchingGUID_Allowed(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + payload := buildLogForwardServiceRequestWithProviders(t, []etw.EtwProvider{ + { + ProviderName: "microsoft.windows.hyperv.compute", + ProviderGUID: "80ce50de-d264-4581-950d-abadeee0d340", + }, + }) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err != nil { + t.Fatalf("modifyServiceSettings with matching Name/GUID returned error: %v", err) + } + + select { + case <-b.sendToGCSCh: + // Forwarded to GCS as expected. + case <-time.After(time.Second): + t.Fatal("timed out waiting for request to be forwarded to GCS") + } +} + +// TestModifyServiceSettings_LogForward_BracedGUID_Allowed verifies that the +// validator accepts GUID strings wrapped in `{...}` braces (the common +// canonical form on Windows). The well-known map stores the un-braced form, +// so the comparison must be brace-insensitive. +func TestModifyServiceSettings_LogForward_BracedGUID_Allowed(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + payload := buildLogForwardServiceRequestWithProviders(t, []etw.EtwProvider{ + { + ProviderName: "microsoft.windows.hyperv.compute", + ProviderGUID: "{80ce50de-d264-4581-950d-abadeee0d340}", + }, + }) + req := newModifyServiceSettingsRequest(payload) + + if err := b.modifyServiceSettings(req); err != nil { + t.Fatalf("modifyServiceSettings with braced matching GUID returned error: %v", err) + } + + select { + case <-b.sendToGCSCh: + // Forwarded to GCS as expected. + case <-time.After(time.Second): + t.Fatal("timed out waiting for request to be forwarded to GCS") + } +} diff --git a/internal/regopolicyinterpreter/regopolicyinterpreter.go b/internal/regopolicyinterpreter/regopolicyinterpreter.go index c83b4ae806..f255aea30e 100644 --- a/internal/regopolicyinterpreter/regopolicyinterpreter.go +++ b/internal/regopolicyinterpreter/regopolicyinterpreter.go @@ -554,9 +554,6 @@ func (r *RegoPolicyInterpreter) compile() error { options := ast.CompileOpts{ EnablePrintStatements: r.logLevel != LogNone, - ParserOptions: ast.ParserOptions{ - RegoVersion: ast.RegoV0, - }, } if compiled, err := ast.CompileModulesWithOpt(modules, options); err == nil { @@ -732,7 +729,6 @@ func (r *RegoPolicyInterpreter) query(rule string, input map[string]interface{}) rego.Query(rule), rego.Input(input), rego.Store(store), - rego.SetRegoVersion(ast.RegoV0), rego.EnablePrintStatements(r.logLevel != LogNone), rego.PrintHook(topdown.NewPrintHook(&buf)), rego.Compiler(r.compiledModules)) diff --git a/internal/tools/securitypolicy/main.go b/internal/tools/securitypolicy/main.go index d5ad89e28b..be1860959d 100644 --- a/internal/tools/securitypolicy/main.go +++ b/internal/tools/securitypolicy/main.go @@ -68,6 +68,7 @@ func main() { config.AllowEnvironmentVariableDropping, config.AllowUnencryptedScratch, config.AllowCapabilityDropping, + config.AllowLogProviderDropping, ) } if err != nil { diff --git a/internal/uvm/start.go b/internal/uvm/start.go index 300cd63b9d..89d7ae475b 100644 --- a/internal/uvm/start.go +++ b/internal/uvm/start.go @@ -292,11 +292,28 @@ func (uvm *UtilityVM) Start(ctx context.Context) (err error) { if uvm.OS() == "windows" && uvm.logForwardingEnabled { // If the UVM is Windows and log forwarding is enabled, set the log sources // and start the log forwarding service. + // + // For confidential (CWCOW) UVMs, a failure here may be a policy + // violation (e.g. log_provider denied by the rego). In that case we + // must fail-close: return the error so the deferred + // uvm.hcsSystem.Terminate above tears the UVM down rather than + // leaving it half-initialised with a known-violating log + // configuration. For non-confidential WCOW, log-forwarding is + // best-effort (transient ETW issues, missing providers, etc. must + // not abort UVM start), so preserve the original log-and-continue + // behaviour. + failClose := uvm.HasConfidentialPolicy() if err := uvm.SetLogSources(ctx); err != nil { e.WithError(err).Error("failed to set log sources") + if failClose { + return fmt.Errorf("failed to set log sources: %w", err) + } } if err := uvm.StartLogForwarding(ctx); err != nil { e.WithError(err).Error("failed to start log forwarding") + if failClose { + return fmt.Errorf("failed to start log forwarding: %w", err) + } } } diff --git a/internal/vm/vmutils/etw/provider_map.go b/internal/vm/vmutils/etw/provider_map.go index 5b35206602..9a37022c2a 100644 --- a/internal/vm/vmutils/etw/provider_map.go +++ b/internal/vm/vmutils/etw/provider_map.go @@ -36,7 +36,7 @@ func GetDefaultLogSources() LogSourcesInfo { } // GetProviderGUIDFromName returns the provider GUID for a given provider name. If the provider name is not found in the map, it returns an empty string. -func getProviderGUIDFromName(providerName string) string { +func GetProviderGUIDFromName(providerName string) string { if guid, ok := etwNameToGUIDMap[strings.ToLower(providerName)]; ok { return guid } @@ -92,8 +92,8 @@ func mergeLogSources(resultSources []Source, userSources []Source) []Source { return resultSources } -// decodeAndUnmarshalLogSources decodes a base64-encoded JSON string and unmarshals it into a LogSourcesInfo. -func decodeAndUnmarshalLogSources(base64EncodedJSONLogConfig string) (LogSourcesInfo, error) { +// DecodeAndUnmarshalLogSources decodes a base64-encoded JSON string and unmarshals it into a LogSourcesInfo. +func DecodeAndUnmarshalLogSources(base64EncodedJSONLogConfig string) (LogSourcesInfo, error) { jsonBytes, err := base64.StdEncoding.DecodeString(base64EncodedJSONLogConfig) if err != nil { return LogSourcesInfo{}, fmt.Errorf("error decoding base64 log config: %w", err) @@ -127,7 +127,7 @@ func resolveGUIDsWithLookup(sources []Source) ([]Source, error) { sources[i].Providers[j].ProviderGUID = strings.ToLower(guid.String()) } if provider.ProviderName != "" && provider.ProviderGUID == "" { - sources[i].Providers[j].ProviderGUID = getProviderGUIDFromName(provider.ProviderName) + sources[i].Providers[j].ProviderGUID = GetProviderGUIDFromName(provider.ProviderName) } } } @@ -147,7 +147,7 @@ func stripRedundantGUIDs(sources []Source) ([]Source, error) { if err != nil { return nil, fmt.Errorf("invalid GUID %q for provider %q: %w", provider.ProviderGUID, provider.ProviderName, err) } - if strings.EqualFold(guid.String(), getProviderGUIDFromName(provider.ProviderName)) { + if strings.EqualFold(guid.String(), GetProviderGUIDFromName(provider.ProviderName)) { sources[i].Providers[j].ProviderGUID = "" } else { // If the GUID doesn't match the well-known GUID for the provider name, @@ -188,19 +188,29 @@ func marshalAndEncodeLogSources(logCfg LogSourcesInfo) (string, error) { // configuration and returns the updated log sources as a base64 encoded JSON string. // If there is an error in the process, it returns the original user provided log sources string. func UpdateLogSources(base64EncodedJSONLogConfig string, useDefaultLogSources bool, includeGUIDs bool) (string, error) { - var resultLogCfg LogSourcesInfo - if useDefaultLogSources { - resultLogCfg = defaultLogSourcesInfo - } - + var userLogSources LogSourcesInfo if base64EncodedJSONLogConfig != "" { - userLogSources, err := decodeAndUnmarshalLogSources(base64EncodedJSONLogConfig) + var err error + userLogSources, err = DecodeAndUnmarshalLogSources(base64EncodedJSONLogConfig) if err != nil { return "", fmt.Errorf("failed to decode and unmarshal user log sources: %w", err) } - resultLogCfg.LogConfig.Sources = mergeLogSources(resultLogCfg.LogConfig.Sources, userLogSources.LogConfig.Sources) + } + return UpdateLogSourcesFromInfo(userLogSources, useDefaultLogSources, includeGUIDs) +} +// UpdateLogSourcesFromInfo is the parsed-input variant of UpdateLogSources for +// callers that already have a LogSourcesInfo in hand (e.g. the gcs-sidecar +// after it decoded the payload for policy enforcement) and want to avoid a +// second base64-decode + JSON-unmarshal round-trip. +// +// An empty userLogSources is equivalent to passing "" to UpdateLogSources. +func UpdateLogSourcesFromInfo(userLogSources LogSourcesInfo, useDefaultLogSources bool, includeGUIDs bool) (string, error) { + var resultLogCfg LogSourcesInfo + if useDefaultLogSources { + resultLogCfg = defaultLogSourcesInfo } + resultLogCfg.LogConfig.Sources = mergeLogSources(resultLogCfg.LogConfig.Sources, userLogSources.LogConfig.Sources) var err error resultLogCfg.LogConfig.Sources, err = applyGUIDPolicy(resultLogCfg.LogConfig.Sources, includeGUIDs) diff --git a/internal/vm/vmutils/etw/provider_map_test.go b/internal/vm/vmutils/etw/provider_map_test.go index 4aa62d861c..55ce1b4ce3 100644 --- a/internal/vm/vmutils/etw/provider_map_test.go +++ b/internal/vm/vmutils/etw/provider_map_test.go @@ -23,9 +23,9 @@ func TestGetProviderGUIDFromName(t *testing.T) { } for _, tt := range tests { - got := getProviderGUIDFromName(tt.name) + got := GetProviderGUIDFromName(tt.name) if got != tt.expected { - t.Errorf("getProviderGUIDFromName(%q) = %q, want %q", tt.name, got, tt.expected) + t.Errorf("GetProviderGUIDFromName(%q) = %q, want %q", tt.name, got, tt.expected) } } } @@ -126,11 +126,11 @@ func buildTestUserLogSources(t *testing.T) LogSourcesInfo { nameOnlyProvider := "Microsoft.Windows.HyperV.Compute" nameAndGUIDProvider := "Microsoft.Windows.Containers.Setup" - guid := getProviderGUIDFromName(nameAndGUIDProvider) + guid := GetProviderGUIDFromName(nameAndGUIDProvider) if guid == "" { t.Fatalf("missing GUID mapping for provider %q", nameAndGUIDProvider) } - if getProviderGUIDFromName(nameOnlyProvider) == "" { + if GetProviderGUIDFromName(nameOnlyProvider) == "" { t.Fatalf("missing GUID mapping for provider %q", nameOnlyProvider) } @@ -185,7 +185,7 @@ func applyExpectedGUIDBehavior(cfg *LogSourcesInfo, includeGUIDs bool) { } } if provider.ProviderName != "" && provider.ProviderGUID == "" { - cfg.LogConfig.Sources[i].Providers[j].ProviderGUID = getProviderGUIDFromName(provider.ProviderName) + cfg.LogConfig.Sources[i].Providers[j].ProviderGUID = GetProviderGUIDFromName(provider.ProviderName) } continue } @@ -195,7 +195,7 @@ func applyExpectedGUIDBehavior(cfg *LogSourcesInfo, includeGUIDs bool) { if err != nil { continue } - if strings.EqualFold(guid.String(), getProviderGUIDFromName(provider.ProviderName)) { + if strings.EqualFold(guid.String(), GetProviderGUIDFromName(provider.ProviderName)) { cfg.LogConfig.Sources[i].Providers[j].ProviderGUID = "" } else { cfg.LogConfig.Sources[i].Providers[j].ProviderGUID = strings.ToLower(guid.String()) diff --git a/pkg/securitypolicy/api.rego b/pkg/securitypolicy/api.rego index d1fca363be..2b04dafd23 100644 --- a/pkg/securitypolicy/api.rego +++ b/pkg/securitypolicy/api.rego @@ -27,4 +27,5 @@ enforcement_points := { "scratch_mount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, "scratch_unmount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, "load_transparency_trust_list": {"introducedVersion": "0.12.0", "default_results": {"allowed": false}, "use_framework": false}, + "log_provider": {"introducedVersion": "0.11.0", "default_results": {"allowed": true, "providers_to_keep": null}, "use_framework": false}, } diff --git a/pkg/securitypolicy/fragment_definition.rego b/pkg/securitypolicy/fragment_definition.rego index 027f7f341d..58a77c51cd 100644 --- a/pkg/securitypolicy/fragment_definition.rego +++ b/pkg/securitypolicy/fragment_definition.rego @@ -1,5 +1,5 @@ default __fragment_parameters_metadata := {} -__fragment_parameters_metadata := data[input.namespace].parameters_api { +__fragment_parameters_metadata := data[input.namespace].parameters_api if { data[input.namespace].parameters_api } parameter(name) := data.framework.extract_parameter(name, __fragment_parameters, __fragment_parameters_metadata) diff --git a/pkg/securitypolicy/fragment_test_policies/platform_rules.rego b/pkg/securitypolicy/fragment_test_policies/platform_rules.rego new file mode 100644 index 0000000000..b8d8e096a4 --- /dev/null +++ b/pkg/securitypolicy/fragment_test_policies/platform_rules.rego @@ -0,0 +1,29 @@ +package fragment + +svn := "1" +framework_version := "0.5.0" + +platform_rules := [ + { + "env_rules": [ + { + "name": "(?i)(FABRIC)_.+", + "name_strategy": "re2", + "value": ".+", + "value_strategy": "re2" + } + ], + "mounts": [ + { + "destination": "/var/run/secrets/kubernetes.io/serviceaccount", + "options": [ + "rbind", + "rshared", + "ro" + ], + "source": "sandbox:///tmp/atlas/emptydir/.+", + "type": "bind" + } + ] + } +] diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index c92f83f73d..7c3b614110 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -10,33 +10,33 @@ version := "@@FRAMEWORK_VERSION@@" # Policies should include .* explicitly at the beginning or end if partial # matches are to be allowed. -anchor_pattern(p) := p { +anchor_pattern(p) := p if { startswith(p, "^") endswith(p, "$") -} else := concat("", ["^", p]) { +} else := concat("", ["^", p]) if { endswith(p, "$") -} else := concat("", [p, "$"]) { +} else := concat("", [p, "$"]) if { startswith(p, "^") } else := concat("", ["^", p, "$"]) -device_mounted(target) { +device_mounted(target) if { data.metadata.devices[target] } -device_mounted(target) { +device_mounted(target) if { data.metadata.rw_devices[target] } default deviceHash_ok := false # test if a device hash exists as a layer in a policy container -deviceHash_ok { +deviceHash_ok if { layer := data.policy.containers[_].layers[_] input.deviceHash == layer } # test if a device hash exists as a layer in a fragment container -deviceHash_ok { +deviceHash_ok if { feed := data.metadata.issuers[_].feeds[_] some fragment in feed layer := fragment.containers[_].layers[_] @@ -45,11 +45,11 @@ deviceHash_ok { default mount_device := {"allowed": false} -mount_target_ok { +mount_target_ok if { regex.match(anchor_pattern(input.mountPathRegex), input.target) } -mount_device := {"metadata": [addDevice], "allowed": true} { +mount_device := {"metadata": [addDevice], "allowed": true} if { not device_mounted(input.target) deviceHash_ok mount_target_ok @@ -64,17 +64,17 @@ mount_device := {"metadata": [addDevice], "allowed": true} { allowed_scratch_fs("ext4") allowed_scratch_fs("xfs") -rwmount_device_encrypt_ok { +rwmount_device_encrypt_ok if { input.encrypted } -rwmount_device_encrypt_ok { +rwmount_device_encrypt_ok if { allow_unencrypted_scratch } default rw_mount_device := {"allowed": false} -rw_mount_device := {"metadata": [addDevice], "allowed": true} { +rw_mount_device := {"metadata": [addDevice], "allowed": true} if { not device_mounted(input.target) rwmount_device_encrypt_ok input.ensureFilesystem @@ -90,7 +90,7 @@ rw_mount_device := {"metadata": [addDevice], "allowed": true} { default unmount_device := {"allowed": false} -unmount_device := {"metadata": [removeDevice], "allowed": true} { +unmount_device := {"metadata": [removeDevice], "allowed": true} if { data.metadata.devices[input.unmountTarget] removeDevice := { @@ -102,7 +102,7 @@ unmount_device := {"metadata": [removeDevice], "allowed": true} { default rw_unmount_device := {"allowed": false} -rw_unmount_device := {"metadata": [removeRWDevice], "allowed": true} { +rw_unmount_device := {"metadata": [removeRWDevice], "allowed": true} if { data.metadata.rw_devices[input.unmountTarget] removeRWDevice := { @@ -120,7 +120,7 @@ default mount_blockdev := {"allowed": false} default unmount_blockdev := {"allowed": false} -layerPaths_ok(layers) { +layerPaths_ok(layers) if { length := count(layers) count(input.layerPaths) == length every i, path in input.layerPaths { @@ -128,7 +128,7 @@ layerPaths_ok(layers) { } } -layerHashes_ok(layers) { +layerHashes_ok(layers) if { length := count(layers) count(input.layerHashes) == length every i, hash in input.layerHashes { @@ -138,45 +138,53 @@ layerHashes_ok(layers) { default overlay_exists := false -overlay_exists { +overlay_exists if { data.metadata.matches[input.containerID] } -overlay_mounted(target) { +overlay_mounted(target) if { data.metadata.overlayTargets[target] } -default candidate_containers := [] +# Note that a valid policy might not even define (data.policy.)containers, if +# all its containers are coming from fragments. This default rule prevents +# breaking other rules in this case. +default policy_containers := [] -candidate_containers := containers { +policy_containers := pc if { semver.compare(policy_framework_version, version) == 0 - - policy_containers := [c | c := data.policy.containers[_]] - fragment_containers := [c | - feed := data.metadata.issuers[_].feeds[_] - fragment := feed[_] - c := fragment.containers[_] - ] - - containers := array.concat(policy_containers, fragment_containers) + pc := data.policy.containers } -candidate_containers := containers { +policy_containers := pc if { semver.compare(policy_framework_version, version) < 0 + pc := apply_defaults("container", data.policy.containers, policy_framework_version) +} - policy_containers := apply_defaults("container", data.policy.containers, policy_framework_version) +candidate_containers := containers if { fragment_containers := [c | feed := data.metadata.issuers[_].feeds[_] fragment := feed[_] c := fragment.containers[_] ] - containers := array.concat(policy_containers, fragment_containers) + containers_raw := array.concat(policy_containers, fragment_containers) + + # Each container definition applied with platform_rules might turn into + # multiple containers if multiple platforms are allowed. We flatten the + # result here. + after_platform_rules := [c2 | + c1 := containers_raw[_] + applied := apply_platform_rules("container", c1) + c2 := applied[_] + ] + + containers := after_platform_rules } default mount_cims := {"allowed": false} -mount_cims := {"metadata": [addMatches], "allowed": true} { +mount_cims := {"metadata": [addMatches], "allowed": true} if { not overlay_exists containers := [container | @@ -196,7 +204,7 @@ mount_cims := {"metadata": [addMatches], "allowed": true} { default mount_overlay := {"allowed": false} -mount_overlay := {"metadata": [addMatches, addOverlayTarget], "allowed": true} { +mount_overlay := {"metadata": [addMatches, addOverlayTarget], "allowed": true} if { not overlay_exists # sanity check, but due to checks in the Go code, this should always pass if @@ -226,7 +234,7 @@ mount_overlay := {"metadata": [addMatches, addOverlayTarget], "allowed": true} { default unmount_overlay := {"allowed": false} -unmount_overlay := {"metadata": [removeOverlayTarget], "allowed": true} { +unmount_overlay := {"metadata": [removeOverlayTarget], "allowed": true} if { overlay_mounted(input.unmountTarget) removeOverlayTarget := { "name": "overlayTargets", @@ -235,7 +243,7 @@ unmount_overlay := {"metadata": [removeOverlayTarget], "allowed": true} { } } -command_ok(command) { +command_ok(command) if { count(input.argList) == count(command) every i, arg in input.argList { command[i] == arg @@ -258,18 +266,18 @@ command_ok(command) { # env_pattern_ok(pattern, strategy, value) tests whether the given string # pattern matches the input value. -env_pattern_ok(pattern, "string", value) { +env_pattern_ok(pattern, "string", value) if { pattern == value } -env_pattern_ok(pattern, "re2", value) { +env_pattern_ok(pattern, "re2", value) if { regex.match(anchor_pattern(pattern), value) } # env_rule_ok accepts both forms of env rules described above, and matches it # against the given env string (of form name=value). -env_rule_ok(rule, env) { +env_rule_ok(rule, env) if { pattern := object.get(rule, "pattern", null) strategy := object.get(rule, "strategy", null) pattern != null @@ -277,7 +285,7 @@ env_rule_ok(rule, env) { env_pattern_ok(pattern, strategy, env) } -env_rule_ok(rule, env) { +env_rule_ok(rule, env) if { rule_name := object.get(rule, "name", null) name_strategy := object.get(rule, "name_strategy", null) rule_value := object.get(rule, "value", null) @@ -297,28 +305,33 @@ env_rule_ok(rule, env) { env_pattern_ok(rule_value, value_strategy, env_value) } -rule_ok(rule, env) { - not rule.required -} - -rule_ok(rule, env) { +# For a required env rule, check that envList contains a matching env var for +# it. +env_required_rule_ok(rule, envList) if { rule.required + some env in envList env_rule_ok(rule, env) } -envList_ok(env_rules, envList) { +# If it's not required, skip the check +env_required_rule_ok(rule, envList) if { + not rule.required +} + +envList_ok(env_rules, envList) if { + # Check that all required rules are satisfied every rule in env_rules { - some env in envList - rule_ok(rule, env) + env_required_rule_ok(rule, envList) } + # Check that any env provided is allowed every env in envList { some rule in env_rules env_rule_ok(rule, env) } } -valid_envs_subset(env_rules) := envs { +valid_envs_subset(env_rules) := envs if { envs := {env | some env in input.envList some rule in env_rules @@ -326,7 +339,7 @@ valid_envs_subset(env_rules) := envs { } } -valid_envs_for_all(items) := envs { +valid_envs_for_all(items) := envs if { allow_environment_variable_dropping # for each item, find a subset of the environment rules @@ -361,65 +374,65 @@ valid_envs_for_all(items) := envs { envs := envs_i } -valid_envs_for_all(items) := envs { +valid_envs_for_all(items) := envs if { not allow_environment_variable_dropping # no dropping allowed, so we just return the input envs := input.envList } -workingDirectory_ok(working_dir) { +workingDirectory_ok(working_dir) if { input.workingDir == working_dir } -privileged_ok(elevation_allowed) { +privileged_ok(elevation_allowed) if { is_linux not input.privileged } -privileged_ok(elevation_allowed) { +privileged_ok(elevation_allowed) if { is_linux input.privileged input.privileged == elevation_allowed } -privileged_ok(no_new_privileges) { +privileged_ok(no_new_privileges) if { # no-op for windows is_windows } -noNewPrivileges_ok(no_new_privileges) { +noNewPrivileges_ok(no_new_privileges) if { is_linux no_new_privileges input.noNewPrivileges } -noNewPrivileges_ok(no_new_privileges) { +noNewPrivileges_ok(no_new_privileges) if { is_linux not no_new_privileges } -noNewPrivileges_ok(obj) { +noNewPrivileges_ok(obj) if { is_windows } -idName_ok(pattern, "any", value) { +idName_ok(pattern, "any", value) if { true } -idName_ok(pattern, "id", value) { +idName_ok(pattern, "id", value) if { pattern == value.id } -idName_ok(pattern, "name", value) { +idName_ok(pattern, "name", value) if { pattern == value.name } -idName_ok(pattern, "re2", value) { +idName_ok(pattern, "re2", value) if { regex.match(anchor_pattern(pattern), value.name) } -user_ok(user) { +user_ok(user) if { is_linux user.umask == input.umask idName_ok(user.user_idname.pattern, user.user_idname.strategy, input.user) @@ -429,21 +442,21 @@ user_ok(user) { } } -user_ok(user) { +user_ok(user) if { is_windows input.user == user } -seccomp_ok(seccomp_profile_sha256) { +seccomp_ok(seccomp_profile_sha256) if { is_linux input.seccompProfileSHA256 == seccomp_profile_sha256 } -seccomp_ok(seccomp_profile_sha256) { +seccomp_ok(seccomp_profile_sha256) if { is_windows } -devices_ok(expected_devices, actual_devices) { +devices_ok(expected_devices, actual_devices) if { # Allow out of order but not duplicates set_expected := {dev | dev := expected_devices[_]} set_actual := {dev | dev := actual_devices[_]} @@ -453,18 +466,18 @@ devices_ok(expected_devices, actual_devices) { default container_started := false -container_started { +container_started if { data.metadata.started[input.containerID] } default container_privileged := false -container_privileged { +container_privileged if { is_linux data.metadata.started[input.containerID].privileged } -capsList_ok(allowed_caps_list, requested_caps_list) { +capsList_ok(allowed_caps_list, requested_caps_list) if { count(allowed_caps_list) == count(requested_caps_list) every cap in requested_caps_list { @@ -478,7 +491,7 @@ capsList_ok(allowed_caps_list, requested_caps_list) { } } -filter_capsList_by_allowed(allowed_caps_list, requested_caps_list) := caps { +filter_capsList_by_allowed(allowed_caps_list, requested_caps_list) := caps if { # find a subset of the capabilities that are valid caps := {cap | some cap in requested_caps_list @@ -487,7 +500,7 @@ filter_capsList_by_allowed(allowed_caps_list, requested_caps_list) := caps { } } -filter_capsList_for_single_container(allowed_caps) := caps { +filter_capsList_for_single_container(allowed_caps) := caps if { bounding := filter_capsList_by_allowed(allowed_caps.bounding, input.capabilities.bounding) effective := filter_capsList_by_allowed(allowed_caps.effective, input.capabilities.effective) inheritable := filter_capsList_by_allowed(allowed_caps.inheritable, input.capabilities.inheritable) @@ -503,7 +516,7 @@ filter_capsList_for_single_container(allowed_caps) := caps { } } -largest_caps_sets_for_all(containers, privileged) := largest_caps_sets { +largest_caps_sets_for_all(containers, privileged) := largest_caps_sets if { filtered := [caps | container := containers[_] capabilities := get_capabilities(container, privileged) @@ -528,7 +541,7 @@ largest_caps_sets_for_all(containers, privileged) := largest_caps_sets { ] } -all_caps_sets_are_equal(sets) := caps { +all_caps_sets_are_equal(sets) := caps if { # if there is more than one set with the same size, we # can only proceed if they are all the same, so we verify # that the intersection is equal to the union. For a single @@ -560,7 +573,7 @@ all_caps_sets_are_equal(sets) := caps { } } -valid_caps_for_all(containers, privileged) := caps { +valid_caps_for_all(containers, privileged) := caps if { is_linux allow_capability_dropping @@ -572,7 +585,7 @@ valid_caps_for_all(containers, privileged) := caps { caps := all_caps_sets_are_equal(largest_caps_sets) } -valid_caps_for_all(containers, privileged) := caps { +valid_caps_for_all(containers, privileged) := caps if { is_linux not allow_capability_dropping @@ -580,7 +593,7 @@ valid_caps_for_all(containers, privileged) := caps { caps := input.capabilities } -caps_ok(allowed_caps, requested_caps) { +caps_ok(allowed_caps, requested_caps) if { is_linux capsList_ok(allowed_caps.bounding, requested_caps.bounding) capsList_ok(allowed_caps.effective, requested_caps.effective) @@ -589,16 +602,16 @@ caps_ok(allowed_caps, requested_caps) { capsList_ok(allowed_caps.ambient, requested_caps.ambient) } -caps_ok(allowed_caps, requested_caps) { +caps_ok(allowed_caps, requested_caps) if { is_windows } -get_capabilities(container, privileged) := capabilities { +get_capabilities(container, privileged) := capabilities if { container.capabilities != null capabilities := container.capabilities } -default_privileged_capabilities := capabilities { +default_privileged_capabilities := capabilities if { caps := {cap | cap := data.defaultPrivilegedCapabilities[_]} capabilities := { "bounding": caps, @@ -609,13 +622,13 @@ default_privileged_capabilities := capabilities { } } -get_capabilities(container, true) := capabilities { +get_capabilities(container, true) := capabilities if { container.capabilities == null container.allow_elevated capabilities := default_privileged_capabilities } -default_unprivileged_capabilities := capabilities { +default_unprivileged_capabilities := capabilities if { caps := {cap | cap := data.defaultUnprivilegedCapabilities[_]} capabilities := { "bounding": caps, @@ -626,13 +639,13 @@ default_unprivileged_capabilities := capabilities { } } -get_capabilities(container, false) := capabilities { +get_capabilities(container, false) := capabilities if { container.capabilities == null container.allow_elevated capabilities := default_unprivileged_capabilities } -get_capabilities(container, privileged) := capabilities { +get_capabilities(container, privileged) := capabilities if { container.capabilities == null not container.allow_elevated capabilities := default_unprivileged_capabilities @@ -644,7 +657,7 @@ create_container := {"metadata": [updateMatches, addStarted], "env_list": env_list, "caps_list": caps_list, "allow_stdio_access": allow_stdio_access, - "allowed": true} { + "allowed": true} if { is_linux not container_started @@ -720,7 +733,7 @@ create_container := {"metadata": [updateMatches, addStarted], create_container := {"metadata": [updateMatches, addStarted], "env_list": env_list, "allow_stdio_access": allow_stdio_access, - "allowed": true} { + "allowed": true} if { is_windows not container_started @@ -777,7 +790,7 @@ create_container := {"metadata": [updateMatches, addStarted], } } -security_ok(current_container) { +security_ok(current_container) if { is_linux noNewPrivileges_ok(current_container.no_new_privileges) privileged_ok(current_container.allow_elevated) @@ -785,34 +798,34 @@ security_ok(current_container) { mountList_ok(current_container.mounts, current_container.allow_elevated) } -security_ok(current_container) { +security_ok(current_container) if { is_windows } -mountSource_ok(constraint, source) { +mountSource_ok(constraint, source) if { startswith(constraint, data.sandboxPrefix) newConstraint := replace(constraint, data.sandboxPrefix, input.sandboxDir) regex.match(anchor_pattern(newConstraint), source) } -mountSource_ok(constraint, source) { +mountSource_ok(constraint, source) if { startswith(constraint, data.hugePagesPrefix) newConstraint := replace(constraint, data.hugePagesPrefix, input.hugePagesDir) regex.match(anchor_pattern(newConstraint), source) } -mountSource_ok(constraint, source) { +mountSource_ok(constraint, source) if { startswith(constraint, data.plan9Prefix) some target, containerID in data.metadata.p9mounts source == target input.containerID == containerID } -mountSource_ok(constraint, source) { +mountSource_ok(constraint, source) if { constraint == source } -mountConstraint_ok(constraint, mount) { +mountConstraint_ok(constraint, mount) if { mount.type == constraint.type mountSource_ok(constraint.source, mount.source) mount.destination != "" @@ -833,17 +846,17 @@ mountConstraint_ok(constraint, mount) { } } -mount_ok(mounts, allow_elevated, mount) { +mount_ok(mounts, allow_elevated, mount) if { some constraint in mounts mountConstraint_ok(constraint, mount) } -mount_ok(mounts, allow_elevated, mount) { +mount_ok(mounts, allow_elevated, mount) if { some constraint in data.defaultMounts mountConstraint_ok(constraint, mount) } -mount_ok(mounts, allow_elevated, mount) { +mount_ok(mounts, allow_elevated, mount) if { allow_elevated some constraint in data.privilegedMounts mountConstraint_ok(constraint, mount) @@ -866,7 +879,7 @@ mount_ok(mounts, allow_elevated, mount) { # We have to allow this special case whether or not this policy currently allows # any privileged containers at all, since a fragment that is loaded in the # future may allow privileged containers. -mount_ok(mounts, allow_elevated, mount) { +mount_ok(mounts, allow_elevated, mount) if { input.isSandboxContainer # we allow allow_elevated to be false since this is what existing policies @@ -883,22 +896,22 @@ mount_ok(mounts, allow_elevated, mount) { "rw" in mount.options } -mountList_ok(mounts, allow_elevated) { +mountList_ok(mounts, allow_elevated) if { is_linux every mount in input.mounts { mount_ok(mounts, allow_elevated, mount) } } -mountList_ok(mounts, allow_elevated) { +mountList_ok(mounts, allow_elevated) if { # no-op for windows is_windows } -is_linux { +is_linux if { data.metadata.operatingsystem[ostype] == "linux" } -is_windows { +is_windows if { data.metadata.operatingsystem[ostype] == "windows" } @@ -907,7 +920,7 @@ default exec_in_container := {"allowed": false} exec_in_container := {"metadata": [updateMatches], "env_list": env_list, "caps_list": caps_list, - "allowed": true} { + "allowed": true} if { is_linux container_started @@ -959,7 +972,7 @@ exec_in_container := {"metadata": [updateMatches], exec_in_container := {"metadata": [updateMatches], "env_list": env_list, - "allowed": true} { + "allowed": true} if { is_windows container_started @@ -1001,7 +1014,7 @@ exec_in_container := {"metadata": [updateMatches], default shutdown_container := {"allowed": false} -shutdown_container := {"metadata": [remove], "allowed": true} { +shutdown_container := {"metadata": [remove], "allowed": true} if { container_started remove := { "name": "matches", @@ -1012,7 +1025,7 @@ shutdown_container := {"metadata": [remove], "allowed": true} { default signal_container_process := {"allowed": false} -signal_container_process := {"metadata": [updateMatches], "allowed": true} { +signal_container_process := {"metadata": [updateMatches], "allowed": true} if { container_started input.isInitProcess containers := [container | @@ -1029,7 +1042,7 @@ signal_container_process := {"metadata": [updateMatches], "allowed": true} { } } -signal_container_process := {"metadata": [updateMatches], "allowed": true} { +signal_container_process := {"metadata": [updateMatches], "allowed": true} if { container_started not input.isInitProcess containers := [container | @@ -1048,18 +1061,18 @@ signal_container_process := {"metadata": [updateMatches], "allowed": true} { } } -signal_ok(signals) { +signal_ok(signals) if { some signal in signals input.signal == signal } -plan9_mounted(target) { +plan9_mounted(target) if { data.metadata.p9mounts[target] } default plan9_mount := {"allowed": false} -plan9_mount := {"metadata": [addPlan9Target], "allowed": true} { +plan9_mount := {"metadata": [addPlan9Target], "allowed": true} if { not plan9_mounted(input.target) some containerID, _ in data.metadata.matches pattern := concat("", ["^", input.rootPrefix, "/", containerID, input.mountPathPrefix, "$"]) @@ -1074,7 +1087,7 @@ plan9_mount := {"metadata": [addPlan9Target], "allowed": true} { default plan9_unmount := {"allowed": false} -plan9_unmount := {"metadata": [removePlan9Target], "allowed": true} { +plan9_unmount := {"metadata": [removePlan9Target], "allowed": true} if { plan9_mounted(input.unmountTarget) removePlan9Target := { "name": "p9mounts", @@ -1093,11 +1106,11 @@ default enforcement_point_info := { "use_framework": false } -enforcement_point_info := {"available": false, "default_results": {"allow": false}, "unknown": false, "invalid": false, "version_missing": true, "use_framework": false} { +enforcement_point_info := {"available": false, "default_results": {"allow": false}, "unknown": false, "invalid": false, "version_missing": true, "use_framework": false} if { policy_api_version == null } -enforcement_point_info := {"available": available, "default_results": default_results, "unknown": false, "invalid": false, "version_missing": false, "use_framework": use_framework} { +enforcement_point_info := {"available": available, "default_results": default_results, "unknown": false, "invalid": false, "version_missing": false, "use_framework": use_framework} if { enforcement_point := data.api.enforcement_points[input.name] semver.compare(data.api.version, enforcement_point.introducedVersion) >= 0 available := semver.compare(policy_api_version, enforcement_point.introducedVersion) >= 0 @@ -1105,14 +1118,14 @@ enforcement_point_info := {"available": available, "default_results": default_re use_framework := enforcement_point.use_framework } -enforcement_point_info := {"available": false, "default_results": {"allow": false}, "unknown": false, "invalid": true, "version_missing": false, "use_framework": false} { +enforcement_point_info := {"available": false, "default_results": {"allow": false}, "unknown": false, "invalid": true, "version_missing": false, "use_framework": false} if { enforcement_point := data.api.enforcement_points[input.name] semver.compare(data.api.version, enforcement_point.introducedVersion) < 0 } default candidate_external_processes := [] -candidate_external_processes := external_processes { +candidate_external_processes := external_processes if { semver.compare(policy_framework_version, version) == 0 policy_external_processes := [e | e := data.policy.external_processes[_]] @@ -1125,7 +1138,7 @@ candidate_external_processes := external_processes { external_processes := array.concat(policy_external_processes, fragment_external_processes) } -candidate_external_processes := external_processes { +candidate_external_processes := external_processes if { semver.compare(policy_framework_version, version) < 0 policy_external_processes := apply_defaults("external_process", data.policy.external_processes, policy_framework_version) @@ -1138,7 +1151,7 @@ candidate_external_processes := external_processes { external_processes := array.concat(policy_external_processes, fragment_external_processes) } -external_process_ok(process) { +external_process_ok(process) if { command_ok(process.command) envList_ok(process.env_rules, input.envList) workingDirectory_ok(process.working_dir) @@ -1148,7 +1161,7 @@ default exec_external := {"allowed": false} exec_external := {"allowed": true, "allow_stdio_access": allow_stdio_access, - "env_list": env_list} { + "env_list": env_list} if { possible_processes := [process | process := candidate_external_processes[_] # NB any change to these narrowing conditions should be reflected in @@ -1178,43 +1191,50 @@ exec_external := {"allowed": true, default get_properties := {"allowed": false} -get_properties := {"allowed": true} { +get_properties := {"allowed": true} if { allow_properties_access } default dump_stacks := {"allowed": false} -dump_stacks := {"allowed": true} { +dump_stacks := {"allowed": true} if { allow_dump_stacks } default runtime_logging := {"allowed": false} -runtime_logging := {"allowed": true} { +runtime_logging := {"allowed": true} if { allow_runtime_logging } -default fragment_containers := [] +# Helpers to get data from the fragment that is currently being loaded. Since +# input.namespace is the package name the fragment loaded as, +# data[input.namespace] can be used to access the fragment. This is only valid +# during a load_fragment call - the content exported by the fragment needs to be +# persisted into the metadata for later enforcement use. (c.f. +# extract_fragment_includes) +default fragment_containers := [] fragment_containers := data[input.namespace].containers default fragment_fragments := [] - fragment_fragments := data[input.namespace].fragments default fragment_external_processes := [] - fragment_external_processes := data[input.namespace].external_processes default fragment_transparency_trust_lists := [] fragment_transparency_trust_lists := data[input.namespace].transparency_trust_lists -apply_defaults(name, raw_values, framework_version) := values { +default fragment_platform_rules := [] +fragment_platform_rules := data[input.namespace].platform_rules + +apply_defaults(name, raw_values, framework_version) := values if { semver.compare(framework_version, version) == 0 values := raw_values } -apply_defaults("container", raw_values, framework_version) := values { +apply_defaults("container", raw_values, framework_version) := values if { semver.compare(framework_version, version) < 0 values := [checked | raw := raw_values[_] @@ -1222,7 +1242,7 @@ apply_defaults("container", raw_values, framework_version) := values { ] } -apply_defaults("external_process", raw_values, framework_version) := values { +apply_defaults("external_process", raw_values, framework_version) := values if { semver.compare(framework_version, version) < 0 values := [checked | raw := raw_values[_] @@ -1230,7 +1250,7 @@ apply_defaults("external_process", raw_values, framework_version) := values { ] } -apply_defaults("fragment", raw_values, framework_version) := values { +apply_defaults("fragment", raw_values, framework_version) := values if { semver.compare(framework_version, version) < 0 values := [checked | raw := raw_values[_] @@ -1242,13 +1262,29 @@ apply_defaults("fragment", raw_values, framework_version) := values { # policy has it, silently ignore as it might be using the name for something # else. -apply_defaults("transparency_trust_lists", raw_values, framework_version) := values { +apply_defaults("transparency_trust_lists", raw_values, framework_version) := values if { + semver.compare(framework_version, version) < 0 + semver.compare(framework_version, "0.5.0") >= 0 + values := raw_values +} + +apply_defaults("transparency_trust_lists", raw_values, framework_version) := values if { + semver.compare(framework_version, "0.5.0") < 0 + values := [] +} + +# platform_rules is introduced in framework version 0.5.0. If an old policy has it, +# silently ignore as it might be using the name for something else. + +apply_defaults("platform_rules", raw_values, framework_version) := values if { semver.compare(framework_version, version) < 0 semver.compare(framework_version, "0.5.0") >= 0 + # This is currently unreachable, otherwise we would call something like + # check_platform_rule here, like above (not defined yet). values := raw_values } -apply_defaults("transparency_trust_lists", raw_values, framework_version) := values { +apply_defaults("platform_rules", raw_values, framework_version) := values if { semver.compare(framework_version, "0.5.0") < 0 values := [] } @@ -1256,13 +1292,14 @@ apply_defaults("transparency_trust_lists", raw_values, framework_version) := val default fragment_framework_version := null fragment_framework_version := data[input.namespace].framework_version -extract_fragment_includes(includes) := fragment { +extract_fragment_includes(includes) := fragment if { framework_version := fragment_framework_version objects := { "containers": apply_defaults("container", fragment_containers, framework_version), "fragments": apply_defaults("fragment", fragment_fragments, framework_version), "external_processes": apply_defaults("external_process", fragment_external_processes, framework_version), "transparency_trust_lists": apply_defaults("transparency_trust_lists", fragment_transparency_trust_lists, framework_version), + "platform_rules": apply_defaults("platform_rules", fragment_platform_rules, framework_version), } fragment := { @@ -1292,15 +1329,15 @@ extract_fragment_includes(includes) := fragment { # This map does not contain any containers / fragments allowed by the top-level # policy itself. The candidate_* rules need to combine both sources. -issuer_exists(iss) { +issuer_exists(iss) if { data.metadata.issuers[iss] } -feed_exists(iss, feed) { +feed_exists(iss, feed) if { data.metadata.issuers[iss].feeds[feed] } -update_issuer(includes) := issuer { +update_issuer(includes) := issuer if { feed_exists(input.issuer, input.feed) old_issuer := data.metadata.issuers[input.issuer] old_fragments := old_issuer.feeds[input.feed] @@ -1309,7 +1346,7 @@ update_issuer(includes) := issuer { issuer := object.union(old_issuer, new_issuer) } -update_issuer(includes) := issuer { +update_issuer(includes) := issuer if { not feed_exists(input.issuer, input.feed) old_issuer := data.metadata.issuers[input.issuer] new_issuer := {"feeds": {input.feed: [extract_fragment_includes(includes)]}} @@ -1317,7 +1354,7 @@ update_issuer(includes) := issuer { issuer := object.union(old_issuer, new_issuer) } -update_issuer(includes) := issuer { +update_issuer(includes) := issuer if { not issuer_exists(input.issuer) issuer := {"feeds": {input.feed: [extract_fragment_includes(includes)]}} } @@ -1326,12 +1363,12 @@ update_issuer(includes) := issuer { # to [] to prevent breaking other rules. default policy_fragments := [] -policy_fragments := pf { +policy_fragments := pf if { semver.compare(policy_framework_version, version) == 0 pf := data.policy.fragments } -policy_fragments := pf { +policy_fragments := pf if { semver.compare(policy_framework_version, version) < 0 pf := apply_defaults("fragment", data.policy.fragments, policy_framework_version) } @@ -1378,7 +1415,7 @@ policy_fragments := pf { default fragment_parameters_for(_, _) := [] -fragment_parameters_for(iss, feed) := params { +fragment_parameters_for(iss, feed) := params if { params_nested := [ p.parameters | p := data.metadata.fragment_parameters[_] @@ -1395,7 +1432,7 @@ fragment_parameters_for(iss, feed) := params { params := array.concat(params_nested, params_policy) } -candidate_fragments := fragments { +candidate_fragments := fragments if { fragment_fragments := [f | feed := data.metadata.issuers[_].feeds[_] fragment := feed[_] @@ -1405,18 +1442,18 @@ candidate_fragments := fragments { fragments := array.concat(policy_fragments, fragment_fragments) } -svn_ok(svn, minimum_svn) { +svn_ok(svn, minimum_svn) if { # deprecated semver.is_valid(svn) semver.is_valid(minimum_svn) semver.compare(svn, minimum_svn) >= 0 } -svn_ok(svn, minimum_svn) { +svn_ok(svn, minimum_svn) if { to_number(svn) >= to_number(minimum_svn) } -fragment_issuer_feed_ok(fragment) { +fragment_issuer_feed_ok(fragment) if { input.issuer == fragment.issuer input.feed == fragment.feed } @@ -1428,22 +1465,22 @@ fragment_issuer_feed_ok(fragment) { # neither the header nor the fragment Rego declares an SVN is tested in # Test_Rego_LoadFragment_MissingSVN. -header_svn_ok(fragment) { +header_svn_ok(fragment) if { not input.has_header_svn } -header_svn_ok(fragment) { +header_svn_ok(fragment) if { input.has_header_svn svn_ok(input.header_svn, fragment.minimum_svn) } -svn_ok_if_defined(minimum_svn) { +svn_ok_if_defined(minimum_svn) if { data[input.namespace].svn # This also works if the svn is 0 not input.has_header_svn svn_ok(data[input.namespace].svn, minimum_svn) } -svn_ok_if_defined(minimum_svn) { +svn_ok_if_defined(minimum_svn) if { data[input.namespace].svn input.has_header_svn # Use to_number as fragment may define svn as a string @@ -1452,7 +1489,7 @@ svn_ok_if_defined(minimum_svn) { } # If not defined in fragment, require SVN to present in the header -svn_ok_if_defined(minimum_svn) { +svn_ok_if_defined(minimum_svn) if { not data[input.namespace].svn input.has_header_svn svn_ok(input.header_svn, minimum_svn) @@ -1470,7 +1507,7 @@ svn_ok_if_defined(minimum_svn) { # feeds of the TTLs containing the key for the receipt. If not set, no receipts # are required. The list is an AND: every required entry must be satisfied. # One receipt can satisfy multiple such requirement entries. -fragment_receipts_ok(fragment) { +fragment_receipts_ok(fragment) if { required := object.get(fragment, "required_receipts", []) every required_issuer in required { receipt_requirement_satisfied(required_issuer) @@ -1485,19 +1522,19 @@ fragment_receipts_ok(fragment) { # - "TTL:": satisfied by a validated receipt that was signed by a key # contributed by a TTL with the given subject. # - a literal ledger name: satisfied by a validated receipt with that issuer. -receipt_requirement_satisfied(required_issuer) { +receipt_requirement_satisfied(required_issuer) if { required_issuer == "*" count(input.receipts) > 0 } -receipt_requirement_satisfied(required_issuer) { +receipt_requirement_satisfied(required_issuer) if { startswith(required_issuer, "TTL:") subject := substring(required_issuer, count("TTL:"), -1) some receipt in input.receipts subject in receipt.ttl_subjects } -receipt_requirement_satisfied(required_issuer) { +receipt_requirement_satisfied(required_issuer) if { required_issuer != "*" not startswith(required_issuer, "TTL:") some receipt in input.receipts @@ -1515,7 +1552,7 @@ default load_fragment := {"allowed": false} # in the header, and thus we could not have checked earlier), and if successful, # add the fragment to the metadata. -load_fragment := {"allowed": true, "parameters": possibleParams} { +load_fragment := {"allowed": true, "parameters": possibleParams} if { not input.fragment_loaded some fragment in candidate_fragments fragment_issuer_feed_ok(fragment) @@ -1525,7 +1562,7 @@ load_fragment := {"allowed": true, "parameters": possibleParams} { possibleParams := fragment_parameters_for(fragment.issuer, fragment.feed) } -load_fragment := {"metadata": array.concat([updateIssuer], updateParameters), "add_module": add_module, "allowed": true} { +load_fragment := {"metadata": array.concat([updateIssuer], updateParameters), "add_module": add_module, "allowed": true} if { input.fragment_loaded some fragment in candidate_fragments fragment_issuer_feed_ok(fragment) @@ -1568,7 +1605,7 @@ load_fragment := {"metadata": array.concat([updateIssuer], updateParameters), "a default policy_transparency_trust_lists := [] policy_transparency_trust_lists := data.policy.transparency_trust_lists -candidate_transparency_trust_lists := ttls { +candidate_transparency_trust_lists := ttls if { fragment_ttls := [r | feed := data.metadata.issuers[_].feeds[_] fragment := feed[_] @@ -1580,7 +1617,7 @@ candidate_transparency_trust_lists := ttls { # The set of ledger names a matching TTL authorizes for the given (issuer, # subject, svn). "*" is a wildcard meaning "any ledger". -ttl_allowed_ledgers_for_issuer_subject_svn(issuer, subject, svn) := allowed_ledgers { +ttl_allowed_ledgers_for_issuer_subject_svn(issuer, subject, svn) := allowed_ledgers if { allowed_ledgers := {l | ttl := candidate_transparency_trust_lists[_] ttl.issuer == issuer @@ -1590,19 +1627,19 @@ ttl_allowed_ledgers_for_issuer_subject_svn(issuer, subject, svn) := allowed_ledg } } -ttl_intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { +ttl_intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result if { not "*" in allowed_ledgers result := {l | l := input_ledgers[_]; l in allowed_ledgers} } -ttl_intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { +ttl_intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result if { "*" in allowed_ledgers result := {l | l := input_ledgers[_]} } default load_transparency_trust_list := {"allowed": false} -load_transparency_trust_list := {"allowed": true, "allowed_ledgers": allowed_ledgers} { +load_transparency_trust_list := {"allowed": true, "allowed_ledgers": allowed_ledgers} if { ttl_ledgers := ttl_allowed_ledgers_for_issuer_subject_svn(input.issuer, input.subject, input.svn) allowed_ledgers := ttl_intersect_or_allow_all_if_wildcard(ttl_ledgers, input.ledgers) count(allowed_ledgers) > 0 @@ -1610,11 +1647,11 @@ load_transparency_trust_list := {"allowed": true, "allowed_ledgers": allowed_led default scratch_mount := {"allowed": false} -scratch_mounted(target) { +scratch_mounted(target) if { data.metadata.scratch_mounts[target] } -scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} { +scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} if { not scratch_mounted(input.target) allow_unencrypted_scratch add_scratch_mount := { @@ -1625,7 +1662,7 @@ scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} { } } -scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} { +scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} if { not scratch_mounted(input.target) not allow_unencrypted_scratch input.encrypted @@ -1639,7 +1676,7 @@ scratch_mount := {"metadata": [add_scratch_mount], "allowed": true} { default scratch_unmount := {"allowed": false} -scratch_unmount := {"metadata": [remove_scratch_mount], "allowed": true} { +scratch_unmount := {"metadata": [remove_scratch_mount], "allowed": true} if { scratch_mounted(input.unmountTarget) remove_scratch_mount := { "name": "scratch_mounts", @@ -1648,11 +1685,52 @@ scratch_unmount := {"metadata": [remove_scratch_mount], "allowed": true} { } } +# Log provider validation for Windows containers. +# +# Two modes (mirrors allow_environment_variable_dropping): +# - allow_log_provider_dropping := false (default, fail-close): every +# requested provider name must appear in allowed_log_providers, otherwise +# the rule denies the entire request. +# - allow_log_provider_dropping := true: providers not in the allow-list are +# silently dropped from providers_to_keep; the call still allows and the +# caller is expected to only forward the remaining providers. +# +# Input: {"providers": [name, ...]} +# Output: {"allowed": bool, "providers_to_keep": [name, ...]} +default log_provider := {"allowed": false, "providers_to_keep": []} + +valid_log_providers := providers if { + allow_log_provider_dropping + + providers := [name | + name := input.providers[_] + some allowed_provider in data.policy.allowed_log_providers + lower(name) == lower(allowed_provider) + ] +} + +valid_log_providers := providers if { + not allow_log_provider_dropping + providers := input.providers +} + +log_providers_ok(providers) if { + every name in providers { + some allowed_provider in data.policy.allowed_log_providers + lower(name) == lower(allowed_provider) + } +} + +log_provider := {"allowed": true, "providers_to_keep": providers} if { + providers := valid_log_providers + log_providers_ok(providers) +} + # Registry changes validation default registry_changes := {"allowed": false} # Helper function to compare registry keys -registry_keys_match(policy_key, input_key) { +registry_keys_match(policy_key, input_key) if { policy_key.hive == input_key.Hive policy_key.name == input_key.Name # Volatile field comparison (default to false if not specified) @@ -1663,7 +1741,7 @@ registry_keys_match(policy_key, input_key) { # Helper function to compare registry values # STRING type -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1672,7 +1750,7 @@ registry_value_matches(policy_value, input_value) { } # EXPANDED_STRING type (uses StringValue field) -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1681,7 +1759,7 @@ registry_value_matches(policy_value, input_value) { } # MULTI_STRING type (uses StringValue field) -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1690,7 +1768,7 @@ registry_value_matches(policy_value, input_value) { } # D_WORD type -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1699,7 +1777,7 @@ registry_value_matches(policy_value, input_value) { } # Q_WORD type -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1708,7 +1786,7 @@ registry_value_matches(policy_value, input_value) { } # BINARY type -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1717,7 +1795,7 @@ registry_value_matches(policy_value, input_value) { } # CUSTOM_TYPE - both CustomType field and BinaryValue must match -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1727,7 +1805,7 @@ registry_value_matches(policy_value, input_value) { } # NONE type - no value to compare, just key and name -registry_value_matches(policy_value, input_value) { +registry_value_matches(policy_value, input_value) if { registry_keys_match(policy_value.key, input_value.Key) policy_value.name == input_value.Name policy_value.type == input_value.Type @@ -1741,7 +1819,7 @@ filtered_registry_values(input_values, policy_values) := [input_val | registry_value_matches(policy_val, input_val) ] -registry_changes := {"allowed": true} { +registry_changes := {"allowed": true} if { containers := data.metadata.matches[input.containerID] container := containers[_] @@ -1762,12 +1840,71 @@ registry_changes := {"allowed": true} { # injected into fragments, and is not otherwise intended to be called by user # directly. -extract_parameter(name, fragment_parameters_obj, parameters_metadata) := fragment_parameters_obj[name] { +extract_parameter(name, fragment_parameters_obj, parameters_metadata) := fragment_parameters_obj[name] if { name in object.keys(fragment_parameters_obj) -} else := parameters_metadata[name]["default"] { +} else := parameters_metadata[name]["default"] if { "default" in object.keys(parameters_metadata[name]) } +default policy_platform_rules := [] + +policy_platform_rules := platform_rules if { + semver.compare(policy_framework_version, version) == 0 + platform_rules := data.policy.platform_rules +} + +# For policy with framework_version < 0.5.0, apply_defaults will ignore +# platform_rules and return []. + +policy_platform_rules := platform_rules if { + semver.compare(policy_framework_version, version) < 0 + platform_rules := apply_defaults("platform_rules", data.policy.platform_rules, policy_framework_version) +} + +default candidate_platform_rules := [] + +candidate_platform_rules := platform_rules if { + fragment_platform_rules := [r | + feed := data.metadata.issuers[_].feeds[_] + fragment := feed[_] + r := fragment.platform_rules[_] + ] + + platform_rules := array.concat(policy_platform_rules, fragment_platform_rules) +} + +# apply_platform_rules("container", c) applies "platform_rules" to the container +# object c, and returns a list of containers which are the input container with +# platform rules applied. The return value may contain more than one container +# if multiple platform rules are defined. + +# No platform rules - return as-is. +apply_platform_rules("container", container) := updated_containers if { + count(candidate_platform_rules) == 0 + updated_containers := [container] +} + +apply_platform_rules("container", container) := updated_containers if { + count(candidate_platform_rules) > 0 + updated_containers := [updated_container | + platform_rule := candidate_platform_rules[_] + updated_container := apply_single_platform_rule("container", container, platform_rule) + ] +} + +apply_single_platform_rule("container", container, platform_rule) := updated_container if { + container_env_rules := object.get(container, "env_rules", []) + updated_env_rules := array.concat(container_env_rules, object.get(platform_rule, "env_rules", [])) + + container_mounts := object.get(container, "mounts", []) + updated_mounts := array.concat(container_mounts, object.get(platform_rule, "mounts", [])) + + updated_container := object.union(container, { + "env_rules": updated_env_rules, + "mounts": updated_mounts, + }) +} + reason := { "errors": errors, "error_objects": error_objects @@ -1777,107 +1914,107 @@ reason := { # Error messages ################################################################ -errors["blockdev mounts are not supported"] { +errors["blockdev mounts are not supported"] if { input.rule in ["mount_blockdev", "unmount_blockdev"] } -errors["deviceHash not found"] { +errors["deviceHash not found"] if { input.rule == "mount_device" not deviceHash_ok } -errors["device already mounted at path"] { +errors["device already mounted at path"] if { input.rule in ["mount_device", "rw_mount_device"] device_mounted(input.target) } -errors["mountpoint invalid"] { +errors["mountpoint invalid"] if { input.rule in ["mount_device", "rw_mount_device"] not mount_target_ok } -errors["no device at path to unmount"] { +errors["no device at path to unmount"] if { input.rule == "unmount_device" not data.metadata.devices[input.unmountTarget] not data.metadata.rw_devices[input.unmountTarget] } -errors["received read-only unmount request, but device provided is read-write"] { +errors["received read-only unmount request, but device provided is read-write"] if { input.rule == "unmount_device" not data.metadata.devices[input.unmountTarget] data.metadata.rw_devices[input.unmountTarget] } -errors["no device at path to unmount"] { +errors["no device at path to unmount"] if { input.rule == "rw_unmount_device" not data.metadata.devices[input.unmountTarget] not data.metadata.rw_devices[input.unmountTarget] } -errors["received read-write unmount request, but device provided is read-only"] { +errors["received read-write unmount request, but device provided is read-only"] if { input.rule == "rw_unmount_device" not data.metadata.rw_devices[input.unmountTarget] data.metadata.devices[input.unmountTarget] } # Error string tested in azcri-containerd Test_RunPodSandboxNotAllowed_WithPolicy_EncryptedScratchPolicy -errors["unencrypted scratch not allowed, non-readonly mount request for SCSI disk must request encryption"] { +errors["unencrypted scratch not allowed, non-readonly mount request for SCSI disk must request encryption"] if { input.rule == "rw_mount_device" not allow_unencrypted_scratch not input.encrypted } -errors["ensureFilesystem must be set on rw device mounts"] { +errors["ensureFilesystem must be set on rw device mounts"] if { input.rule == "rw_mount_device" not input.ensureFilesystem } -errors["rw device mounts uses a filesystem that is not allowed"] { +errors["rw device mounts uses a filesystem that is not allowed"] if { input.rule == "rw_mount_device" not allowed_scratch_fs(input.filesystem) } -errors["container already started"] { +errors["container already started"] if { input.rule == "create_container" container_started } -errors["container not started"] { +errors["container not started"] if { input.rule in ["exec_in_container", "shutdown_container", "signal_container_process"] not container_started } -errors["overlay has already been mounted"] { +errors["overlay has already been mounted"] if { input.rule == "mount_overlay" overlay_exists } default overlay_matches := false -overlay_matches { +overlay_matches if { some container in candidate_containers layerPaths_ok(container.layers) } -errors["no overlay at path to unmount"] { +errors["no overlay at path to unmount"] if { input.rule == "unmount_overlay" not overlay_mounted(input.unmountTarget) } -errors["no matching containers for overlay"] { +errors["no matching containers for overlay"] if { input.rule == "mount_overlay" not overlay_matches } default privileged_matches := false -privileged_matches { +privileged_matches if { input.rule == "create_container" some container in data.metadata.matches[input.containerID] privileged_ok(container.allow_elevated) } -errors["privileged escalation not allowed"] { +errors["privileged escalation not allowed"] if { is_linux input.rule in ["create_container"] not privileged_matches @@ -1885,45 +2022,45 @@ errors["privileged escalation not allowed"] { default command_matches := false -command_matches { +command_matches if { input.rule == "create_container" some container in data.metadata.matches[input.containerID] command_ok(container.command) } -command_matches { +command_matches if { input.rule == "exec_in_container" some container in data.metadata.matches[input.containerID] some process in container.exec_processes command_ok(process.command) } -command_matches { +command_matches if { input.rule == "exec_external" some process in candidate_external_processes command_ok(process.command) } -errors["invalid command"] { +errors["invalid command"] if { input.rule in ["create_container", "exec_in_container", "exec_external"] not command_matches } -env_matches(env) { +env_matches(env) if { input.rule in ["create_container", "exec_in_container"] some container in data.metadata.matches[input.containerID] some rule in container.env_rules env_rule_ok(rule, env) } -env_matches(env) { +env_matches(env) if { input.rule in ["exec_external"] some process in candidate_external_processes some rule in process.env_rules env_rule_ok(rule, env) } -errors[envError] { +errors[envError] if { input.rule in ["create_container", "exec_in_container", "exec_external"] bad_envs := [invalid | env := input.envList[_] @@ -1936,12 +2073,12 @@ errors[envError] { envError := concat(" ", ["invalid env list:", concat(",", bad_envs)]) } -env_rule_matches(rule) { +env_rule_matches(rule) if { some env in input.envList env_rule_ok(rule, env) } -errors["missing required environment variable"] { +errors["missing required environment variable"] if { is_linux input.rule == "create_container" @@ -1974,7 +2111,7 @@ errors["missing required environment variable"] { count(containers) > 0 } -errors["missing required environment variable"] { +errors["missing required environment variable"] if { input.rule == "exec_in_container" container_started @@ -2005,7 +2142,7 @@ errors["missing required environment variable"] { count(containers) > 0 } -errors["missing required environment variable"] { +errors["missing required environment variable"] if { input.rule == "exec_external" possible_processes := [process | @@ -2035,7 +2172,7 @@ errors["missing required environment variable"] { # All environment variables matches some rule in some container, but there are # no containers with exactly the given combination of rules (i.e. for every # container, there is at least one mismatching rule). -errors["invalid env list"] { +errors["invalid env list"] if { input.rule in ["create_container"] every container in data.metadata.matches[input.containerID] { @@ -2055,29 +2192,29 @@ errors["invalid env list"] { default workingDirectory_matches := false -workingDirectory_matches { +workingDirectory_matches if { input.rule in ["create_container", "exec_in_container"] some container in data.metadata.matches[input.containerID] workingDirectory_ok(container.working_dir) } -workingDirectory_matches { +workingDirectory_matches if { input.rule == "exec_external" some process in candidate_external_processes workingDirectory_ok(process.working_dir) } -errors["invalid working directory"] { +errors["invalid working directory"] if { input.rule in ["create_container", "exec_in_container", "exec_external"] not workingDirectory_matches } -mount_matches(mount) { +mount_matches(mount) if { some container in data.metadata.matches[input.containerID] mount_ok(container.mounts, container.allow_elevated, mount) } -errors[mountError] { +errors[mountError] if { is_linux input.rule == "create_container" bad_mounts := [mount.destination | @@ -2091,13 +2228,13 @@ errors[mountError] { default signal_allowed := false -signal_allowed { +signal_allowed if { input.isInitProcess some container in data.metadata.matches[input.containerID] signal_ok(container.signals) } -signal_allowed { +signal_allowed if { not input.isInitProcess some container in data.metadata.matches[input.containerID] some process in container.exec_processes @@ -2105,46 +2242,46 @@ signal_allowed { signal_ok(process.signals) } -errors["target isn't allowed to receive the signal"] { +errors["target isn't allowed to receive the signal"] if { input.rule == "signal_container_process" not signal_allowed } -errors["device already mounted at path"] { +errors["device already mounted at path"] if { input.rule == "plan9_mount" plan9_mounted(input.target) } -errors["no device at path to unmount"] { +errors["no device at path to unmount"] if { input.rule == "plan9_unmount" not plan9_mounted(input.unmountTarget) } default fragment_issuer_matches := false -fragment_issuer_matches { +fragment_issuer_matches if { some fragment in candidate_fragments fragment.issuer == input.issuer } -errors["invalid fragment issuer"] { +errors["invalid fragment issuer"] if { input.rule == "load_fragment" not fragment_issuer_matches } default fragment_feed_matches := false -fragment_feed_matches { +fragment_feed_matches if { some fragment in candidate_fragments fragment.issuer == input.issuer fragment.feed == input.feed } -fragment_feed_matches { +fragment_feed_matches if { input.feed in data.metadata.issuers[input.issuer] } -errors["invalid fragment feed"] { +errors["invalid fragment feed"] if { input.rule == "load_fragment" fragment_issuer_matches not fragment_feed_matches @@ -2152,7 +2289,7 @@ errors["invalid fragment feed"] { default fragment_version_is_valid := false -fragment_version_is_valid { +fragment_version_is_valid if { some fragment in candidate_fragments input.fragment_loaded fragment.issuer == input.issuer @@ -2160,7 +2297,7 @@ fragment_version_is_valid { svn_ok(data[input.namespace].svn, fragment.minimum_svn) } -fragment_version_is_valid { +fragment_version_is_valid if { some fragment in candidate_fragments fragment.issuer == input.issuer fragment.feed == input.feed @@ -2170,7 +2307,7 @@ fragment_version_is_valid { default svn_mismatch := false -svn_mismatch { +svn_mismatch if { some fragment in candidate_fragments fragment.issuer == input.issuer fragment.feed == input.feed @@ -2179,7 +2316,7 @@ svn_mismatch { semver.is_valid(fragment.minimum_svn) } -svn_mismatch { +svn_mismatch if { some fragment in candidate_fragments fragment.issuer == input.issuer fragment.feed == input.feed @@ -2189,7 +2326,7 @@ svn_mismatch { } # Header SVN is always a number, not semver -svn_mismatch { +svn_mismatch if { some fragment in candidate_fragments fragment.issuer == input.issuer fragment.feed == input.feed @@ -2200,7 +2337,7 @@ svn_mismatch { default header_svn_not_match_fragment := false -header_svn_not_match_fragment { +header_svn_not_match_fragment if { input.has_header_svn some fragment in candidate_fragments fragment.issuer == input.issuer @@ -2212,7 +2349,7 @@ header_svn_not_match_fragment { default missing_svn := false -missing_svn { +missing_svn if { not input.has_header_svn some fragment in candidate_fragments fragment.issuer == input.issuer @@ -2221,7 +2358,7 @@ missing_svn { not data[input.namespace].svn } -errors["fragment svn is below the specified minimum"] { +errors["fragment svn is below the specified minimum"] if { input.rule == "load_fragment" fragment_feed_matches input.fragment_loaded @@ -2229,14 +2366,14 @@ errors["fragment svn is below the specified minimum"] { not fragment_version_is_valid } -errors["fragment svn and the specified minimum are different types"] { +errors["fragment svn and the specified minimum are different types"] if { input.rule == "load_fragment" fragment_feed_matches input.fragment_loaded svn_mismatch } -errors[svnMismatchError] { +errors[svnMismatchError] if { input.rule == "load_fragment" fragment_feed_matches input.fragment_loaded @@ -2245,7 +2382,7 @@ errors[svnMismatchError] { svnMismatchError := sprintf("svn in header %v does not match svn in fragment rego %v", [input.header_svn, data[input.namespace].svn]) } -errors["missing fragment svn in either header or rego payload"] { +errors["missing fragment svn in either header or rego payload"] if { input.rule == "load_fragment" fragment_feed_matches input.fragment_loaded @@ -2253,7 +2390,7 @@ errors["missing fragment svn in either header or rego payload"] { } # This will result in one error per missing receipt requirement -errors[receipt_error] { +errors[receipt_error] if { input.rule == "load_fragment" not input.fragment_loaded some fragment in candidate_fragments @@ -2266,19 +2403,19 @@ errors[receipt_error] { default ttl_matches := false -ttl_matches { +ttl_matches if { some ttl in candidate_transparency_trust_lists ttl.issuer == input.issuer ttl.subject == input.subject svn_ok(input.svn, ttl.minimum_svn) } -errors["no TTL candidate matches the provided TTL's issuer, subject and svn"] { +errors["no TTL candidate matches the provided TTL's issuer, subject and svn"] if { input.rule == "load_transparency_trust_list" not ttl_matches } -errors["The provided TTL does not contain any ledgers it is allowed to load"] { +errors["The provided TTL does not contain any ledgers it is allowed to load"] if { input.rule == "load_transparency_trust_list" ttl_matches ttl_ledgers := ttl_allowed_ledgers_for_issuer_subject_svn(input.issuer, input.subject, input.svn) @@ -2286,33 +2423,38 @@ errors["The provided TTL does not contain any ledgers it is allowed to load"] { count(allowed_ledgers) == 0 } -errors["scratch already mounted at path"] { +errors["scratch already mounted at path"] if { input.rule == "scratch_mount" scratch_mounted(input.target) } -errors["unencrypted scratch not allowed"] { +errors["unencrypted scratch not allowed"] if { input.rule == "scratch_mount" not allow_unencrypted_scratch not input.encrypted } -errors["no scratch at path to unmount"] { +errors["no scratch at path to unmount"] if { input.rule == "scratch_unmount" not scratch_mounted(input.unmountTarget) } -errors[framework_version_error] { +errors["log provider not allowed by policy"] if { + input.rule == "log_provider" + not log_provider.allowed +} + +errors[framework_version_error] if { policy_framework_version == null framework_version_error := concat(" ", ["framework_version is missing. Current version:", version]) } -errors[framework_version_error] { +errors[framework_version_error] if { semver.compare(policy_framework_version, version) > 0 framework_version_error := concat(" ", ["framework_version is ahead of the current version:", policy_framework_version, "is greater than", version]) } -errors[fragment_framework_version_error] { +errors[fragment_framework_version_error] if { input.rule == "load_fragment" input.fragment_loaded input.namespace @@ -2320,7 +2462,7 @@ errors[fragment_framework_version_error] { fragment_framework_version_error := concat(" ", ["fragment framework_version is missing. Current version:", version]) } -errors[fragment_framework_version_error] { +errors[fragment_framework_version_error] if { input.rule == "load_fragment" input.fragment_loaded input.namespace @@ -2328,7 +2470,7 @@ errors[fragment_framework_version_error] { fragment_framework_version_error := concat(" ", ["fragment framework_version is ahead of the current version:", fragment_framework_version, "is greater than", version]) } -errors["containers only distinguishable by allow_stdio_access"] { +errors["containers only distinguishable by allow_stdio_access"] if { is_linux input.rule == "create_container" @@ -2377,7 +2519,7 @@ errors["containers only distinguishable by allow_stdio_access"] { c.allow_stdio_access != allow_stdio_access } -errors["containers only distinguishable by allow_stdio_access"] { +errors["containers only distinguishable by allow_stdio_access"] if { is_windows input.rule == "create_container" @@ -2415,7 +2557,7 @@ errors["containers only distinguishable by allow_stdio_access"] { c.allow_stdio_access != allow_stdio_access } -errors["external processes only distinguishable by allow_stdio_access"] { +errors["external processes only distinguishable by allow_stdio_access"] if { input.rule == "exec_external" possible_processes := [process | @@ -2444,13 +2586,13 @@ errors["external processes only distinguishable by allow_stdio_access"] { default noNewPrivileges_matches := false -noNewPrivileges_matches { +noNewPrivileges_matches if { input.rule == "create_container" some container in data.metadata.matches[input.containerID] noNewPrivileges_ok(container.no_new_privileges) } -noNewPrivileges_matches { +noNewPrivileges_matches if { input.rule == "exec_in_container" some container in data.metadata.matches[input.containerID] some process in container.exec_processes @@ -2459,7 +2601,7 @@ noNewPrivileges_matches { noNewPrivileges_ok(process.no_new_privileges) } -errors["invalid noNewPrivileges"] { +errors["invalid noNewPrivileges"] if { is_linux input.rule in ["create_container", "exec_in_container"] not noNewPrivileges_matches @@ -2467,13 +2609,13 @@ errors["invalid noNewPrivileges"] { default user_matches := false -user_matches { +user_matches if { input.rule == "create_container" some container in data.metadata.matches[input.containerID] user_ok(container.user) } -user_matches { +user_matches if { input.rule == "exec_in_container" some container in data.metadata.matches[input.containerID] some process in container.exec_processes @@ -2482,12 +2624,12 @@ user_matches { user_ok(process.user) } -errors["invalid user"] { +errors["invalid user"] if { input.rule in ["create_container", "exec_in_container"] not user_matches } -errors["capabilities don't match"] { +errors["capabilities don't match"] if { is_linux input.rule == "create_container" @@ -2527,7 +2669,7 @@ errors["capabilities don't match"] { count(possible_after_caps_containers) == 0 } -errors["capabilities don't match"] { +errors["capabilities don't match"] if { is_linux input.rule == "exec_in_container" @@ -2565,7 +2707,7 @@ errors["capabilities don't match"] { count(possible_after_caps_containers) == 0 } -errors["devices not supported"] { +errors["devices not supported"] if { is_linux input.rule == "create_container" not devices_ok([], input.devices) @@ -2573,7 +2715,7 @@ errors["devices not supported"] { # covers exec_in_container as well. it shouldn't be possible to ever get # an exec_in_container as it "inherits" capabilities rules from create_container -errors["containers only distinguishable by capabilties"] { +errors["containers only distinguishable by capabilties"] if { is_linux input.rule == "create_container" @@ -2613,13 +2755,13 @@ errors["containers only distinguishable by capabilties"] { default seccomp_matches := false -seccomp_matches { +seccomp_matches if { input.rule == "create_container" some container in data.metadata.matches[input.containerID] seccomp_ok(container.seccomp_profile_sha256) } -errors["invalid seccomp"] { +errors["invalid seccomp"] if { is_linux input.rule == "create_container" not seccomp_matches @@ -2627,12 +2769,12 @@ errors["invalid seccomp"] { default error_objects := null -error_objects := containers { +error_objects := containers if { input.rule == "create_container" containers := data.metadata.matches[input.containerID] } -error_objects := processes { +error_objects := processes if { input.rule == "exec_in_container" processes := [process | container := data.metadata.matches[input.containerID][_] @@ -2640,12 +2782,12 @@ error_objects := processes { ] } -error_objects := processes { +error_objects := processes if { input.rule == "exec_external" processes := candidate_external_processes } -error_objects := fragments { +error_objects := fragments if { input.rule == "load_fragment" fragments := candidate_fragments } @@ -2656,12 +2798,12 @@ error_objects := fragments { ################################################################################ -check_container(raw_container, framework_version) := container { +check_container(raw_container, framework_version) := container if { semver.compare(framework_version, version) == 0 container := raw_container } -check_container(raw_container, framework_version) := container { +check_container(raw_container, framework_version) := container if { semver.compare(framework_version, version) < 0 container := { # Base fields @@ -2682,22 +2824,22 @@ check_container(raw_container, framework_version) := container { } } -check_no_new_privileges(raw_container, framework_version) := no_new_privileges { +check_no_new_privileges(raw_container, framework_version) := no_new_privileges if { semver.compare(framework_version, "0.2.0") >= 0 no_new_privileges := raw_container.no_new_privileges } -check_no_new_privileges(raw_container, framework_version) := no_new_privileges { +check_no_new_privileges(raw_container, framework_version) := no_new_privileges if { semver.compare(framework_version, "0.2.0") < 0 no_new_privileges := false } -check_user(raw_container, framework_version) := user { +check_user(raw_container, framework_version) := user if { semver.compare(framework_version, "0.2.1") >= 0 user := raw_container.user } -check_user(raw_container, framework_version) := user { +check_user(raw_container, framework_version) := user if { semver.compare(framework_version, "0.2.1") < 0 user := { "umask": "0022", @@ -2714,12 +2856,12 @@ check_user(raw_container, framework_version) := user { } } -check_capabilities(raw_container, framework_version) := capabilities { +check_capabilities(raw_container, framework_version) := capabilities if { semver.compare(framework_version, "0.2.2") >= 0 capabilities := raw_container.capabilities } -check_capabilities(raw_container, framework_version) := capabilities { +check_capabilities(raw_container, framework_version) := capabilities if { semver.compare(framework_version, "0.2.2") < 0 # we cannot determine a reasonable default at the time this is called, # which is either during `mount_overlay` or `load_fragment`, and so @@ -2728,32 +2870,32 @@ check_capabilities(raw_container, framework_version) := capabilities { capabilities := null } -check_seccomp_profile_sha256(raw_container, framework_version) := seccomp_profile_sha256 { +check_seccomp_profile_sha256(raw_container, framework_version) := seccomp_profile_sha256 if { semver.compare(framework_version, "0.2.3") >= 0 seccomp_profile_sha256 := raw_container.seccomp_profile_sha256 } -check_seccomp_profile_sha256(raw_container, framework_version) := seccomp_profile_sha256 { +check_seccomp_profile_sha256(raw_container, framework_version) := seccomp_profile_sha256 if { semver.compare(framework_version, "0.2.3") < 0 seccomp_profile_sha256 := "" } -check_signals(raw_container, framework_version) := signals { +check_signals(raw_container, framework_version) := signals if { semver.compare(framework_version, "0.4.1") >= 0 signals := raw_container.signals } -check_signals(raw_container, framework_version) := signals { +check_signals(raw_container, framework_version) := signals if { semver.compare(framework_version, "0.4.1") < 0 signals := array.concat(raw_container.signals, [9, 15]) } -check_external_process(raw_process, framework_version) := process { +check_external_process(raw_process, framework_version) := process if { semver.compare(framework_version, version) == 0 process := raw_process } -check_external_process(raw_process, framework_version) := process { +check_external_process(raw_process, framework_version) := process if { semver.compare(framework_version, version) < 0 process := { # Base fields @@ -2765,12 +2907,12 @@ check_external_process(raw_process, framework_version) := process { } } -check_fragment(raw_fragment, framework_version) := fragment { +check_fragment(raw_fragment, framework_version) := fragment if { semver.compare(framework_version, version) == 0 fragment := raw_fragment } -check_fragment(raw_fragment, framework_version) := fragment { +check_fragment(raw_fragment, framework_version) := fragment if { semver.compare(framework_version, version) < 0 fragment := { # Base fields @@ -2803,12 +2945,13 @@ allow_dump_stacks := data.policy.allow_dump_stacks allow_runtime_logging := data.policy.allow_runtime_logging allow_environment_variable_dropping := data.policy.allow_environment_variable_dropping allow_unencrypted_scratch := data.policy.allow_unencrypted_scratch +allow_log_provider_dropping := data.policy.allow_log_provider_dropping # all flags not in the base set need to have default logic applied default allow_capability_dropping := false -allow_capability_dropping := flag { +allow_capability_dropping := flag if { semver.compare(policy_framework_version, "0.2.2") >= 0 flag := data.policy.allow_capability_dropping } diff --git a/pkg/securitypolicy/open_door.rego b/pkg/securitypolicy/open_door.rego index c2aec0f039..5ccefab441 100644 --- a/pkg/securitypolicy/open_door.rego +++ b/pkg/securitypolicy/open_door.rego @@ -26,3 +26,4 @@ load_fragment := {"allowed": true} scratch_mount := {"allowed": true} scratch_unmount := {"allowed": true} load_transparency_trust_list := {"allowed": true} +log_provider := {"allowed": true} diff --git a/pkg/securitypolicy/opts.go b/pkg/securitypolicy/opts.go index a11685abc4..9446f9dd30 100644 --- a/pkg/securitypolicy/opts.go +++ b/pkg/securitypolicy/opts.go @@ -115,6 +115,13 @@ func WithAllowEnvVarDropping(allow bool) PolicyConfigOpt { } } +func WithAllowLogProviderDropping(allow bool) PolicyConfigOpt { + return func(config *PolicyConfig) error { + config.AllowLogProviderDropping = allow + return nil + } +} + func WithAllowCapabilityDropping(allow bool) PolicyConfigOpt { return func(config *PolicyConfig) error { config.AllowCapabilityDropping = allow diff --git a/pkg/securitypolicy/policy.rego b/pkg/securitypolicy/policy.rego index 6bdd1b3aa2..2ad87995f1 100644 --- a/pkg/securitypolicy/policy.rego +++ b/pkg/securitypolicy/policy.rego @@ -29,4 +29,5 @@ load_fragment := data.framework.load_fragment scratch_mount := data.framework.scratch_mount scratch_unmount := data.framework.scratch_unmount load_transparency_trust_list := data.framework.load_transparency_trust_list +log_provider := data.framework.log_provider reason := data.framework.reason diff --git a/pkg/securitypolicy/policy_with_platform_rules.rego b/pkg/securitypolicy/policy_with_platform_rules.rego new file mode 100644 index 0000000000..a250992398 --- /dev/null +++ b/pkg/securitypolicy/policy_with_platform_rules.rego @@ -0,0 +1,108 @@ +package policy + +api_version := "@@API_VERSION@@" +framework_version := "@@FRAMEWORK_VERSION@@" + +fragments := [ + { + "feed": "@@FRAGMENT_FEED@@", + "includes": [ + "containers", + "fragments" + ], + "issuer": "@@FRAGMENT_ISSUER@@", + "minimum_svn": "0" + } +] + +platform_rules := [ + { + "env_rules": [ + { + "name": "(?i)(FABRIC)_.+", + "name_strategy": "re2", + "value": ".+", + "value_strategy": "re2" + } + ], + "mounts": [ + { + "destination": "/var/run/secrets/kubernetes.io/serviceaccount", + "options": [ + "rbind", + "rshared", + "ro" + ], + "source": "sandbox:///tmp/atlas/emptydir/.+", + "type": "bind" + } + ] + } +] + +containers := [ + { + "allow_elevated": false, + "allow_stdio_access": true, + "capabilities": { + "ambient": [], + "bounding": [], + "effective": [], + "inheritable": [], + "permitted": [] + }, + "command": [ "bash" ], + "env_rules": [], + "exec_processes": [], + "layers": [ + "0000000000000000000000000000000000000000000000000000000000000000", + ], + "mounts": [], + "no_new_privileges": false, + "seccomp_profile_sha256": "", + "signals": [], + "user": { + "group_idnames": [ + { + "pattern": "", + "strategy": "any" + } + ], + "umask": "0022", + "user_idname": { + "pattern": "", + "strategy": "any" + } + }, + "working_dir": "/" + } +] + +allow_properties_access := true +allow_dump_stacks := false +allow_runtime_logging := false +allow_environment_variable_dropping := true +allow_unencrypted_scratch := false +allow_capability_dropping := true + +mount_device := data.framework.mount_device +rw_mount_device := data.framework.rw_mount_device +unmount_device := data.framework.unmount_device +rw_unmount_device := data.framework.rw_unmount_device +mount_overlay := data.framework.mount_overlay +unmount_overlay := data.framework.unmount_overlay +mount_cims := data.framework.mount_cims +create_container := data.framework.create_container +exec_in_container := data.framework.exec_in_container +exec_external := data.framework.exec_external +shutdown_container := data.framework.shutdown_container +signal_container_process := data.framework.signal_container_process +plan9_mount := data.framework.plan9_mount +plan9_unmount := data.framework.plan9_unmount +get_properties := data.framework.get_properties +dump_stacks := data.framework.dump_stacks +runtime_logging := data.framework.runtime_logging +load_fragment := data.framework.load_fragment +scratch_mount := data.framework.scratch_mount +scratch_unmount := data.framework.scratch_unmount +reason := data.framework.reason diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index 4ece06a815..433d130981 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -25,7 +25,6 @@ import ( "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" rpi "github.com/Microsoft/hcsshim/internal/regopolicyinterpreter" "github.com/blang/semver/v4" - "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" oci "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" @@ -97,7 +96,6 @@ func init() { func Test_RegoTemplates(t *testing.T) { query := rego.New( rego.Query("data.api"), - rego.SetRegoVersion(ast.RegoV0), rego.Module("api.rego", APICode)) ctx := context.Background() @@ -127,7 +125,6 @@ func Test_RegoTemplates(t *testing.T) { func verifyPolicyRules(apiVersion string, enforcementPoints map[string]interface{}, policyCode string) error { query := rego.New( rego.Query("data.policy"), - rego.SetRegoVersion(ast.RegoV0), rego.Module("policy.rego", policyCode), rego.Module("framework.rego", FrameworkCode), ) @@ -879,6 +876,30 @@ func setupRegoFragmentSVNMismatchTestConfig(gc *generatedConstraints) (*regoFrag return setupRegoFragmentTestConfig(gc, 2, []string{"containers"}, []string{}, false, false, false, true) } +func makeContainerFromGeneratedFragment(fragment *regoFragment) *regoFragmentContainer { + container := fragment.selectContainer() + + envList := buildEnvironmentVariablesFromEnvRules(container.EnvRules, testRand) + sandboxID := testDataGenerator.uniqueSandboxID() + user := buildIDNameFromConfig(container.User.UserIDName, testRand) + groups := buildGroupIDNamesFromUser(container.User, testRand) + capabilities := copyLinuxCapabilities(container.Capabilities.toExternal()) + seccomp := container.SeccompProfileSHA256 + + mounts := container.Mounts + mountSpec := buildMountSpecFromMountArray(mounts, sandboxID, testRand) + return ®oFragmentContainer{ + container: container, + envList: envList, + sandboxID: sandboxID, + mounts: mountSpec.Mounts, + user: user, + groups: groups, + capabilities: &capabilities, + seccomp: seccomp, + } +} + func setupRegoFragmentTestConfig(gc *generatedConstraints, numFragments int, includes []string, excludes []string, svnError bool, sameIssuer bool, sameFeed bool, svnMismatch bool) (tc *regoFragmentTestConfig, err error) { gc.fragments = generateFragments(testRand, int32(numFragments)) @@ -902,27 +923,7 @@ func setupRegoFragmentTestConfig(gc *generatedConstraints, numFragments int, inc externalProcesses := make([]*externalProcess, numFragments) plan9Mounts := make([]string, numFragments) for i, fragment := range fragments { - container := fragment.selectContainer() - - envList := buildEnvironmentVariablesFromEnvRules(container.EnvRules, testRand) - sandboxID := testDataGenerator.uniqueSandboxID() - user := buildIDNameFromConfig(container.User.UserIDName, testRand) - groups := buildGroupIDNamesFromUser(container.User, testRand) - capabilities := copyLinuxCapabilities(container.Capabilities.toExternal()) - seccomp := container.SeccompProfileSHA256 - - mounts := container.Mounts - mountSpec := buildMountSpecFromMountArray(mounts, sandboxID, testRand) - containers[i] = ®oFragmentContainer{ - container: container, - envList: envList, - sandboxID: sandboxID, - mounts: mountSpec.Mounts, - user: user, - groups: groups, - capabilities: &capabilities, - seccomp: seccomp, - } + containers[i] = makeContainerFromGeneratedFragment(fragment) for _, include := range fragment.info.includes { switch include { @@ -2024,6 +2025,7 @@ func (constraints *generatedConstraints) toPolicy() *securityPolicyInternal { AllowEnvironmentVariableDropping: constraints.allowEnvironmentVariableDropping, AllowUnencryptedScratch: constraints.allowUnencryptedScratch, AllowCapabilityDropping: constraints.allowCapabilityDropping, + AllowLogProviderDropping: constraints.allowLogProviderDropping, } } @@ -2285,6 +2287,7 @@ func generateConstraints(r *rand.Rand, maxContainers int32) *generatedConstraint namespace: generateFragmentNamespace(testRand), svn: generateSVN(testRand), allowCapabilityDropping: false, + allowLogProviderDropping: false, ctx: context.Background(), } } @@ -2949,6 +2952,7 @@ type generatedConstraints struct { namespace string svn string allowCapabilityDropping bool + allowLogProviderDropping bool ctx context.Context } @@ -2964,6 +2968,7 @@ type generatedWindowsConstraints struct { namespace string svn string allowCapabilityDropping bool + allowLogProviderDropping bool ctx context.Context } @@ -2978,6 +2983,7 @@ func (constraints *generatedWindowsConstraints) toPolicy() *securityPolicyWindow AllowEnvironmentVariableDropping: constraints.allowEnvironmentVariableDropping, AllowUnencryptedScratch: constraints.allowUnencryptedScratch, AllowCapabilityDropping: constraints.allowCapabilityDropping, + AllowLogProviderDropping: constraints.allowLogProviderDropping, } } @@ -3022,6 +3028,7 @@ func generateWindowsConstraints(r *rand.Rand, maxContainers int32) *generatedWin allowEnvironmentVariableDropping: false, allowUnencryptedScratch: false, allowCapabilityDropping: false, + allowLogProviderDropping: false, namespace: generateFragmentNamespace(r), svn: generateSVN(r), ctx: context.Background(), diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 5a36461070..85b87fa6b0 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -82,6 +82,7 @@ func Test_MarshalRego_Policy(t *testing.T) { p.allowEnvironmentVariableDropping, p.allowUnencryptedScratch, p.allowCapabilityDropping, + p.allowLogProviderDropping, ) if err != nil { t.Error(err) @@ -9345,6 +9346,374 @@ func Test_Rego_GetUserInfo_EtcPasswdOnly(t *testing.T) { } } +// Test platform rules in fragment, container in main policy +func Test_Rego_PlatformRules_InFragment1(t *testing.T) { + f := func(gc *generatedConstraints, skipLoadFragment bool, fragmentDontIncludePlatformRules bool) bool { + platformFragment := fragment{ + issuer: testDataGenerator.uniqueFragmentIssuer(), + feed: "infra", + minimumSVN: "1", + includes: []string{ + "platform_rules", + }, + } + + if fragmentDontIncludePlatformRules { + platformFragment.includes = []string{ + "containers", + } + } + + gc.fragments = []*fragment{&platformFragment} + + securityPolicy := gc.toPolicy() + defaultMounts := generateMounts(testRand) + privilegedMounts := generateMounts(testRand) + + policy, err := newRegoPolicy(securityPolicy.marshalRego(), + toOCIMounts(defaultMounts), + toOCIMounts(privilegedMounts), testOSType) + if err != nil { + t.Fatalf("failed to create rego policy: %v", err) + } + + if !skipLoadFragment { + err = policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: platformFragment.issuer, Feed: platformFragment.feed, Rego: platformRulesFragmentPolicyCode}) + if err != nil { + t.Fatalf("failed to load infra fragment: %v", err) + } + } + + container := selectContainerFromContainerList(gc.containers, testRand) + containerID, err := mountImageForContainer(policy, container) + if err != nil { + t.Errorf("failed to mount image for container: %v", err) + return false + } + + tc, err := createTestContainerSpec(gc, containerID, container, false, policy, defaultMounts, privilegedMounts) + if err != nil { + t.Fatalf("failed to create test container spec: %v", err) + } + tc.envList = append(tc.envList, "Fabric_NodeIPOrFqdn=10.0.0.1") + tc.mounts = append(tc.mounts, oci.Mount{ + Source: fmt.Sprintf("/run/gcs/c/%s/sandboxMounts/tmp/atlas/emptydir/serviceaccount", tc.sandboxID), + Destination: "/var/run/secrets/kubernetes.io/serviceaccount", + Type: "bind", + Options: []string{"rbind", "rshared", "ro"}, + }) + + envsToKeep, _, _, err := tc.policy.EnforceCreateContainerPolicy(gc.ctx, tc.sandboxID, tc.containerID, tc.argList, tc.envList, tc.workingDir, tc.mounts, false, tc.noNewPrivileges, tc.user, tc.groups, tc.umask, tc.capabilities, tc.seccomp) + + if skipLoadFragment || fragmentDontIncludePlatformRules { + if err == nil { + t.Error("expected error due to missing platform rules, got nil") + return false + } + assertDecisionJSONContains(t, err, "invalid env list: Fabric_NodeIPOrFqdn") + assertDecisionJSONContains(t, err, "invalid mount list: /var/run/secrets/kubernetes.io/serviceaccount") + return true + } + + // getting an error means something is broken + if err != nil { + t.Error(err) + return false + } + + slices.Sort(tc.envList) + slices.Sort(envsToKeep) + if !slices.Equal(tc.envList, envsToKeep) { + t.Errorf("expected envs to keep = %v, got %v", tc.envList, envsToKeep) + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_PlatformRules_InFragment1 failed: %v", err) + } +} + +// Test platform rule and container both in separate fragment +func Test_Rego_PlatformRules_InFragment2(t *testing.T) { + f := func(gc *generatedConstraints, loadPlatformRulesFragmentFirst bool, skipLoadPlatformRulesFragment bool) bool { + // Generate some random fragments in the policy + gc.fragments = generateFragments(testRand, maxFragmentsInGeneratedConstraints) + // Pick one or two to use for container create test + containerFragments := selectFragmentsFromConstraints(gc, testRand.Intn(2)+1, []string{"containers"}, []string{}, false, frameworkVersion, false) + // Now add platform rules fragment in a random position + platformFragment := fragment{ + issuer: testDataGenerator.uniqueFragmentIssuer(), + feed: "infra", + minimumSVN: "1", + includes: []string{ + "platform_rules", + }, + } + gc.fragments = append(gc.fragments, &platformFragment) + testRand.Shuffle(len(gc.fragments), func(i, j int) { + gc.fragments[i], gc.fragments[j] = gc.fragments[j], gc.fragments[i] + }) + + containers := make([]*regoFragmentContainer, len(containerFragments)) + + // c.f. setupRegoFragmentTestConfig + for i, fragment := range containerFragments { + containers[i] = makeContainerFromGeneratedFragment(fragment) + + containers[i].envList = append(containers[i].envList, "Fabric_NodeIPOrFqdn=10.0.0.1") + containers[i].mounts = append(containers[i].mounts, oci.Mount{ + Source: fmt.Sprintf("/run/gcs/c/%s/sandboxMounts/tmp/atlas/emptydir/serviceaccount", containers[i].sandboxID), + Destination: "/var/run/secrets/kubernetes.io/serviceaccount", + Type: "bind", + Options: []string{"rbind", "rshared", "ro"}, + }) + + code := fragment.constraints.toFragment().marshalRego() + fragment.code = setFrameworkVersion(code, frameworkVersion) + } + + securityPolicy := gc.toPolicy() + defaultMounts := generateMounts(testRand) + privilegedMounts := generateMounts(testRand) + + policy, err := newRegoPolicy(securityPolicy.marshalRego(), + toOCIMounts(defaultMounts), + toOCIMounts(privilegedMounts), testOSType) + if err != nil { + t.Fatalf("failed to create rego policy: %v", err) + } + + if loadPlatformRulesFragmentFirst { + if !skipLoadPlatformRulesFragment { + err = policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: platformFragment.issuer, Feed: platformFragment.feed, Rego: platformRulesFragmentPolicyCode}) + if err != nil { + t.Fatalf("failed to load platform rules fragment: %v", err) + } + } + for _, containerFragment := range containerFragments { + err = policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: containerFragment.info.issuer, Feed: containerFragment.info.feed, Rego: containerFragment.code}) + if err != nil { + t.Fatalf("failed to load container fragment: %v", err) + } + } + } else { + for _, containerFragment := range containerFragments { + err = policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: containerFragment.info.issuer, Feed: containerFragment.info.feed, Rego: containerFragment.code}) + if err != nil { + t.Fatalf("failed to load container fragment: %v", err) + } + } + if !skipLoadPlatformRulesFragment { + err = policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: platformFragment.issuer, Feed: platformFragment.feed, Rego: platformRulesFragmentPolicyCode}) + if err != nil { + t.Fatalf("failed to load platform rules fragment: %v", err) + } + } + } + + for _, container := range containers { + containerID, err := mountImageForContainer(policy, container.container) + if err != nil { + t.Errorf("failed to mount image for container: %v", err) + return false + } + + _, _, _, err = policy.EnforceCreateContainerPolicy(gc.ctx, + container.sandboxID, + containerID, + copyStrings(container.container.Command), + copyStrings(container.envList), + container.container.WorkingDir, + copyMounts(container.mounts), + false, + container.container.NoNewPrivileges, + container.user, + container.groups, + container.container.User.Umask, + container.capabilities, + container.seccomp, + ) + + if !skipLoadPlatformRulesFragment { + if err != nil { + t.Errorf("unable to create container from fragment: %v", err) + return false + } + } else { + if err == nil { + t.Error("expected error due to missing platform rules, got nil") + return false + } + assertDecisionJSONContains(t, err, "invalid env list: Fabric_NodeIPOrFqdn") + assertDecisionJSONContains(t, err, "invalid mount list: /var/run/secrets/kubernetes.io/serviceaccount") + } + } + + return true + } + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_PlatformRules_InFragment2 failed: %v", err) + } +} + +// Test platform rules and container both in main policy +func Test_Rego_PlatformRules_InPolicy1(t *testing.T) { + // We don't actually use fragment in this test, but need some value as + // placeholder. + fragmentFeed := testDataGenerator.uniqueFragmentFeed() + fragmentIssuer := testDataGenerator.uniqueFragmentIssuer() + + rego := getPolicyCode_PolicyWithPlatformRules(fragmentFeed, fragmentIssuer) + defaultMounts := generateMounts(testRand) + privilegedMounts := generateMounts(testRand) + + p, err := newRegoPolicy(rego, + toOCIMounts(defaultMounts), + toOCIMounts(privilegedMounts), testOSType) + if err != nil { + t.Errorf("unable to create policy with platform rules: %v", err) + } + + container := &securityPolicyContainer{ + Command: []string{"bash"}, + EnvRules: []EnvRuleConfig{ + { + Required: true, + UseNameValue: true, + Name: "Fabric_NodeIPOrFqdn", + NameStrategy: EnvVarRuleString, + Value: "10.0.0.1", + ValueStrategy: EnvVarRuleString, + }, + }, + Layers: []string{ + paramTestImageBaseLayer, + }, + WorkingDir: "/", + Mounts: []mountInternal{ + { + Source: "sandbox:///tmp/atlas/emptydir/.+", + Destination: "/var/run/secrets/kubernetes.io/serviceaccount", + Type: "bind", + Options: []string{"rbind", "rshared", "ro"}, + }, + }, + User: UserConfig{ + UserIDName: generateIDNameConfig(testRand), + GroupIDNames: []IDNameConfig{ + generateIDNameConfig(testRand), + }, + Umask: "0022", + }, + Capabilities: &capabilitiesInternal{ + Ambient: []string{}, + Bounding: []string{}, + Effective: []string{}, + Inheritable: []string{}, + Permitted: []string{}, + }, + } + containerID, err := mountImageForContainer(p, container) + if err != nil { + t.Fatalf("failed to mount image for container: %v", err) + } + + tc, err := createTestContainerSpec(&generatedConstraints{ + ctx: context.Background(), + }, containerID, container, false, p, defaultMounts, privilegedMounts) + if err != nil { + t.Fatalf("failed to create test container spec: %v", err) + } + + envsToKeep, _, _, err := tc.policy.EnforceCreateContainerPolicy(tc.ctx, tc.sandboxID, tc.containerID, tc.argList, tc.envList, tc.workingDir, tc.mounts, false, tc.noNewPrivileges, tc.user, tc.groups, tc.umask, tc.capabilities, tc.seccomp) + + // getting an error means something is broken + if err != nil { + t.Fatal(err) + } + + if !slices.Equal(tc.envList, envsToKeep) { + t.Fatalf("expected envs to keep = %v, got %v", tc.envList, envsToKeep) + } +} + +// Test platform rule in main policy, container in fragment +func Test_Rego_PlatformRules_InPolicy2(t *testing.T) { + // Generate a random fragment. We only have one "slot" in + // policy_with_platform_rules.rego for an external fragment so we only do + // one. generateFragments' argument is only a minimum, so truncate to one to + // keep selectFragmentsFromConstraints' random pick aligned with + // fragments[0] below. + fragments := generateFragments(testRand, 1)[:1] + containerFragments := selectFragmentsFromConstraints(&generatedConstraints{ + fragments: fragments, + }, 1, []string{"containers"}, []string{}, false, frameworkVersion, false) + containers := make([]*regoFragmentContainer, len(containerFragments)) + + for i, fragment := range containerFragments { + containers[i] = makeContainerFromGeneratedFragment(fragment) + + containers[i].envList = append(containers[i].envList, "Fabric_NodeIPOrFqdn=10.0.0.1") + containers[i].mounts = append(containers[i].mounts, oci.Mount{ + Source: fmt.Sprintf("/run/gcs/c/%s/sandboxMounts/tmp/atlas/emptydir/serviceaccount", containers[i].sandboxID), + Destination: "/var/run/secrets/kubernetes.io/serviceaccount", + Type: "bind", + Options: []string{"rbind", "rshared", "ro"}, + }) + + code := fragment.constraints.toFragment().marshalRego() + fragment.code = setFrameworkVersion(code, frameworkVersion) + } + + rego := getPolicyCode_PolicyWithPlatformRules(fragments[0].feed, fragments[0].issuer) + defaultMounts := generateMounts(testRand) + privilegedMounts := generateMounts(testRand) + + p, err := newRegoPolicy(rego, + toOCIMounts(defaultMounts), + toOCIMounts(privilegedMounts), testOSType) + if err != nil { + t.Fatalf("unable to create policy with platform rules: %v", err) + } + + for _, containerFragment := range containerFragments { + err = p.LoadFragment(context.Background(), LoadFragmentOptions{Issuer: containerFragment.info.issuer, Feed: containerFragment.info.feed, Rego: containerFragment.code}) + if err != nil { + t.Fatalf("failed to load container fragment: %v", err) + } + } + + for _, container := range containers { + containerID, err := mountImageForContainer(p, container.container) + if err != nil { + t.Fatalf("failed to mount image for container: %v", err) + } + + _, _, _, err = p.EnforceCreateContainerPolicy(context.Background(), + container.sandboxID, + containerID, + copyStrings(container.container.Command), + copyStrings(container.envList), + container.container.WorkingDir, + copyMounts(container.mounts), + false, + container.container.NoNewPrivileges, + container.user, + container.groups, + container.container.User.Umask, + container.capabilities, + container.seccomp, + ) + + if err != nil { + t.Fatalf("unable to create container from fragment: %v", err) + } + } +} + type getUserInfoTestCase struct { userStrs []string additionalGIDs []uint32 @@ -9871,3 +10240,18 @@ func fragmentParameterTestCheckOneEnvWithLayer( assertDecisionJSONContains(t, err, "invalid env list") } } + +//go:embed fragment_test_policies/platform_rules.rego +var platformRulesFragmentPolicyCode string + +//go:embed policy_with_platform_rules.rego +var policyWithPlatformRules string + +func getPolicyCode_PolicyWithPlatformRules(fragmentFeed, fragmentIssuer string) string { + s := policyWithPlatformRules + s = strings.ReplaceAll(s, "@@API_VERSION@@", apiVersion) + s = strings.ReplaceAll(s, "@@FRAMEWORK_VERSION@@", frameworkVersion) + s = strings.ReplaceAll(s, "@@FRAGMENT_FEED@@", fragmentFeed) + s = strings.ReplaceAll(s, "@@FRAGMENT_ISSUER@@", fragmentIssuer) + return s +} diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 48ce19394c..e9eeff622d 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1510,3 +1510,212 @@ func substituteUVMPath(sandboxID string, m mountInternal) mountInternal { _ = sandboxID return m } + +// Tests for log provider enforcement + +// newLogProviderTestPolicy builds a Rego policy whose allowed_log_providers +// list contains the given providers and returns the compiled enforcer. +// Pass no providers to get an empty allow-list. +// +// allow_log_provider_dropping is left unset so the test exercises the +// default fail-close mode. Use newLogProviderTestPolicyWithDropping to flip +// the mode. +func newLogProviderTestPolicy(t *testing.T, allowedProviders ...string) *regoEnforcer { + t.Helper() + return newLogProviderTestPolicyWithDropping(t, false, allowedProviders...) +} + +// newLogProviderTestPolicyWithDropping is the more general helper used by the +// mode-specific tests. It compiles a Rego policy that defines +// allowed_log_providers, sets allow_log_provider_dropping to dropping, and +// routes log_provider through the framework rule. +func newLogProviderTestPolicyWithDropping(t *testing.T, dropping bool, allowedProviders ...string) *regoEnforcer { + t.Helper() + var listLines string + for _, p := range allowedProviders { + listLines += fmt.Sprintf("\t\t%q,\n", p) + } + rego := fmt.Sprintf(`package policy + api_version := "%s" + framework_version := "%s" + + allow_log_provider_dropping := %t + + allowed_log_providers := [ +%s ] + + log_provider := data.framework.log_provider + `, apiVersion, frameworkVersion, dropping, listLines) + + policy, err := newRegoPolicy(rego, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + return policy +} + +func Test_Rego_EnforceLogProviderPolicy_Allowed_Windows(t *testing.T) { + policy := newLogProviderTestPolicy(t, + "microsoft.windows.hyperv.compute", + "microsoft-windows-guest-network-service", + ) + + kept, err := policy.EnforceLogProviderPolicy(context.Background(), + []string{"microsoft.windows.hyperv.compute"}) + if err != nil { + t.Errorf("expected allowed provider to pass: %v", err) + } + if len(kept) != 1 || kept[0] != "microsoft.windows.hyperv.compute" { + t.Errorf("expected kept=[microsoft.windows.hyperv.compute]; got %v", kept) + } +} + +func Test_Rego_EnforceLogProviderPolicy_Denied_Windows(t *testing.T) { + policy := newLogProviderTestPolicy(t, "microsoft.windows.hyperv.compute") + + _, err := policy.EnforceLogProviderPolicy(context.Background(), + []string{"some-malicious-provider"}) + if err == nil { + t.Errorf("expected unknown provider to be denied") + } +} + +func Test_Rego_EnforceLogProviderPolicy_CaseInsensitive_Windows(t *testing.T) { + policy := newLogProviderTestPolicy(t, "microsoft.windows.hyperv.compute") + + kept, err := policy.EnforceLogProviderPolicy(context.Background(), + []string{"Microsoft.Windows.Hyperv.Compute"}) + if err != nil { + t.Errorf("expected case-insensitive match to pass: %v", err) + } + // Rego preserves the input casing; we just confirm the name survived. + if len(kept) != 1 || kept[0] != "Microsoft.Windows.Hyperv.Compute" { + t.Errorf("expected kept=[Microsoft.Windows.Hyperv.Compute]; got %v", kept) + } +} + +func Test_Rego_EnforceLogProviderPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { + policy, err := newRegoPolicy(openDoorRego, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + + ctx := context.Background() + kept, err := policy.EnforceLogProviderPolicy(ctx, []string{"any-provider-at-all"}) + if err != nil { + t.Errorf("open door should allow any provider: %v", err) + } + if len(kept) != 1 || kept[0] != "any-provider-at-all" { + t.Errorf("open door should keep the requested provider; got %v", kept) + } +} + +func Test_Rego_EnforceLogProviderPolicy_EmptyAllowList_DeniesAll_Windows(t *testing.T) { + policy := newLogProviderTestPolicy(t) + + _, err := policy.EnforceLogProviderPolicy(context.Background(), + []string{"microsoft.windows.hyperv.compute"}) + if err == nil { + t.Errorf("expected empty allow list to deny all providers") + } +} + +// Test_Rego_EnforceLogProviderPolicy_PreFeatureAPIVersion_Allows_Windows pins +// the non-regression behaviour for policies authored before log_provider was +// introduced (api.rego entry: introducedVersion=0.11.0, default_results.allowed=true). +// Such policies omit allowed_log_providers entirely; EnforceLogProviderPolicy +// must return the input list unchanged with no error so existing CWCOW/WCOW +// policies do not break when the framework gains the new enforcement point. +func Test_Rego_EnforceLogProviderPolicy_PreFeatureAPIVersion_Allows_Windows(t *testing.T) { + // A pre-feature policy does not define log_provider at all (it was + // authored before the rule existed). The version-gated default_results + // path only fires when the policy has no rule for the enforcement + // point — including `log_provider := data.framework.log_provider` + // here would shadow the default and route through the framework rule, + // which defaults to deny. + rego := fmt.Sprintf(`package policy + api_version := "0.10.0" + framework_version := "%s" + `, frameworkVersion) + + policy, err := newRegoPolicy(rego, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + + ctx := context.Background() + kept, err := policy.EnforceLogProviderPolicy(ctx, + []string{"any-provider-not-in-any-list"}) + if err != nil { + t.Errorf("expected pre-0.11.0 policy to allow any provider via default_results: %v", err) + } + // default_results.providers_to_keep is null, so getProvidersToKeep + // returns the input list unchanged. + if len(kept) != 1 || kept[0] != "any-provider-not-in-any-list" { + t.Errorf("expected kept=[any-provider-not-in-any-list]; got %v", kept) + } +} + +// Test_Rego_EnforceLogProviderPolicy_EmptyProviderName_Denied_Windows pins +// the behaviour for the host-scrubbed-unknown-provider edge case: when the +// providerName is the empty string, no allow-list entry can match (allow-lists +// never contain ""), so enforcement must deny. +func Test_Rego_EnforceLogProviderPolicy_EmptyProviderName_Denied_Windows(t *testing.T) { + policy := newLogProviderTestPolicy(t, "microsoft.windows.hyperv.compute") + + _, err := policy.EnforceLogProviderPolicy(context.Background(), []string{""}) + if err == nil { + t.Errorf("expected empty providerName to be denied") + } +} + +// Test_Rego_EnforceLogProviderPolicy_Dropping_KeepsSubset_Windows exercises +// the silent-drop mode: when allow_log_provider_dropping := true the call +// allows even if some requested providers are not on the allow-list, and only +// the matching subset is returned. +func Test_Rego_EnforceLogProviderPolicy_Dropping_KeepsSubset_Windows(t *testing.T) { + policy := newLogProviderTestPolicyWithDropping(t, true, + "microsoft.windows.hyperv.compute", + "microsoft-windows-guest-network-service", + ) + + kept, err := policy.EnforceLogProviderPolicy(context.Background(), []string{ + "microsoft.windows.hyperv.compute", + "some-bogus-provider", + "microsoft-windows-guest-network-service", + }) + if err != nil { + t.Errorf("dropping mode should allow regardless of unknown providers: %v", err) + } + + keptSet := make(map[string]struct{}, len(kept)) + for _, n := range kept { + keptSet[n] = struct{}{} + } + if _, ok := keptSet["microsoft.windows.hyperv.compute"]; !ok { + t.Errorf("expected 'microsoft.windows.hyperv.compute' kept; got %v", kept) + } + if _, ok := keptSet["microsoft-windows-guest-network-service"]; !ok { + t.Errorf("expected 'microsoft-windows-guest-network-service' kept; got %v", kept) + } + if _, ok := keptSet["some-bogus-provider"]; ok { + t.Errorf("expected 'some-bogus-provider' to be dropped; got %v", kept) + } +} + +// Test_Rego_EnforceLogProviderPolicy_FailClose_AnyMissDenies_Windows confirms +// the inverse of the dropping test: with allow_log_provider_dropping := false +// (default) even one unknown provider in a batch fails the entire call. +func Test_Rego_EnforceLogProviderPolicy_FailClose_AnyMissDenies_Windows(t *testing.T) { + policy := newLogProviderTestPolicyWithDropping(t, false, + "microsoft.windows.hyperv.compute", + ) + + _, err := policy.EnforceLogProviderPolicy(context.Background(), []string{ + "microsoft.windows.hyperv.compute", + "some-bogus-provider", + }) + if err == nil { + t.Errorf("fail-close mode should deny when any provider is unknown") + } +} diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index ebc439ba51..27b61c41c8 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -68,6 +68,12 @@ type PolicyConfig struct { // all containers within a pod to be run without scratch encryption. AllowUnencryptedScratch bool `json:"allow_unencrypted_scratch" toml:"allow_unencrypted_scratch"` AllowCapabilityDropping bool `json:"allow_capability_dropping" toml:"allow_capability_dropping"` + // AllowLogProviderDropping controls how EnforceLogProviderPolicy handles + // requested ETW providers that are not on the allow-list. When false + // (default, fail-close) any disallowed provider causes the entire + // modifyServiceSettings call to be denied. When true, disallowed providers + // are silently dropped and the call continues with the kept subset. + AllowLogProviderDropping bool `json:"allow_log_provider_dropping" toml:"allow_log_provider_dropping"` } func NewPolicyConfig(opts ...PolicyConfigOpt) (*PolicyConfig, error) { diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index da759a4925..eab781f527 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -19,6 +19,7 @@ type securityPolicyInternal struct { AllowEnvironmentVariableDropping bool AllowUnencryptedScratch bool AllowCapabilityDropping bool + AllowLogProviderDropping bool } // Internal version of Windows SecurityPolicy @@ -32,6 +33,7 @@ type securityPolicyWindowsInternal struct { AllowEnvironmentVariableDropping bool AllowUnencryptedScratch bool AllowCapabilityDropping bool + AllowLogProviderDropping bool } type securityPolicyFragment struct { @@ -97,6 +99,7 @@ func newSecurityPolicyInternal( allowDropEnvironmentVariables bool, allowUnencryptedScratch bool, allowDropCapabilities bool, + allowLogProviderDropping bool, ) (*securityPolicyInternal, error) { containersInternal, err := containersToInternal(containers) if err != nil { @@ -113,6 +116,7 @@ func newSecurityPolicyInternal( AllowEnvironmentVariableDropping: allowDropEnvironmentVariables, AllowUnencryptedScratch: allowUnencryptedScratch, AllowCapabilityDropping: allowDropCapabilities, + AllowLogProviderDropping: allowLogProviderDropping, }, nil } diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index 1cdcb8177f..08460ad716 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -67,6 +67,7 @@ type OSAwareMarshalFunc func( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowLogProviderDropping bool, ) (string, error) // osAwareMarshalRego handles both Linux and Windows containers @@ -83,6 +84,7 @@ func osAwareMarshalRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowLogProviderDropping bool, ) (string, error) { if allowAll { if len(linuxContainers) > 0 || len(windowsContainers) > 0 { @@ -98,7 +100,8 @@ func osAwareMarshalRego( } return marshalRego(allowAll, linuxContainers, externalProcesses, fragments, allowPropertiesAccess, allowDumpStacks, allowRuntimeLogging, - allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping) + allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowLogProviderDropping) case "windows": if len(linuxContainers) > 0 { @@ -106,7 +109,8 @@ func osAwareMarshalRego( } return marshalWindowsRego(allowAll, windowsContainers, externalProcesses, fragments, allowPropertiesAccess, allowDumpStacks, allowRuntimeLogging, - allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping) + allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowLogProviderDropping) default: return "", fmt.Errorf("unsupported OS type: %s", osType) @@ -125,6 +129,7 @@ func marshalWindowsRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowLogProviderDropping bool, ) (string, error) { if allowAll { if len(containers) > 0 { @@ -149,6 +154,7 @@ func marshalWindowsRego( AllowEnvironmentVariableDropping: allowEnvironmentVariableDropping, AllowUnencryptedScratch: allowUnencryptedScratch, AllowCapabilityDropping: allowCapabilityDropping, + AllowLogProviderDropping: allowLogProviderDropping, } return policy.marshalWindowsRego(), nil @@ -167,6 +173,7 @@ func marshalJSON( _ bool, _ bool, _ bool, + _ bool, ) (string, error) { var policy *SecurityPolicy if allowAll { @@ -198,6 +205,7 @@ func marshalRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowLogProviderDropping bool, ) (string, error) { if allowAll { if len(containers) > 0 { @@ -217,6 +225,7 @@ func marshalRego( allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowLogProviderDropping, ) if err != nil { return "", err @@ -251,6 +260,7 @@ func MarshalPolicy( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapbilitiesDropping bool, + allowLogProviderDropping bool, ) (string, error) { if marshaller == "" { marshaller = defaultMarshaller @@ -272,6 +282,7 @@ func MarshalPolicy( allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapbilitiesDropping, + allowLogProviderDropping, ) } } @@ -610,6 +621,7 @@ func (p securityPolicyInternal) marshalRego() string { writeLine(builder, "allow_environment_variable_dropping := %t", p.AllowEnvironmentVariableDropping) writeLine(builder, "allow_unencrypted_scratch := %t", p.AllowUnencryptedScratch) writeLine(builder, "allow_capability_dropping := %t", p.AllowCapabilityDropping) + writeLine(builder, "allow_log_provider_dropping := %t", p.AllowLogProviderDropping) result := strings.Replace(policyRegoTemplate, "@@OBJECTS@@", builder.String(), 1) result = strings.Replace(result, "@@API_VERSION@@", apiVersion, 1) result = strings.Replace(result, "@@FRAMEWORK_VERSION@@", frameworkVersion, 1) @@ -635,6 +647,7 @@ func (p securityPolicyWindowsInternal) marshalWindowsRego() string { writeLine(builder, "allow_environment_variable_dropping := %t", p.AllowEnvironmentVariableDropping) writeLine(builder, "allow_unencrypted_scratch := %t", p.AllowUnencryptedScratch) writeLine(builder, "allow_capability_dropping := %t", p.AllowCapabilityDropping) + writeLine(builder, "allow_log_provider_dropping := %t", p.AllowLogProviderDropping) result := strings.Replace(policyRegoTemplate, "@@OBJECTS@@", builder.String(), 1) result = strings.Replace(result, "@@API_VERSION@@", apiVersion, 1) result = strings.Replace(result, "@@FRAMEWORK_VERSION@@", frameworkVersion, 1) diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 83e7ed4b7c..396f95ab6c 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -164,6 +164,14 @@ type SecurityPolicyEnforcer interface { GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) (err error) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error + // EnforceLogProviderPolicy validates a batch of requested ETW provider + // names against the policy's allowed_log_providers list. It returns the + // subset of provider names that the caller should forward to the inbox + // GCS, plus any policy error. When the policy has + // allow_log_provider_dropping := true, providers not on the allow-list are + // silently dropped from the returned slice; otherwise the whole call is + // denied (returning a non-nil error) if any provider is not allowed. + EnforceLogProviderPolicy(ctx context.Context, providerNames []string) ([]string, error) WithMetadataRollback(fn func() error) error } @@ -373,6 +381,10 @@ func (OpenDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.C return nil } +func (OpenDoorSecurityPolicyEnforcer) EnforceLogProviderPolicy(_ context.Context, providerNames []string) ([]string, error) { + return providerNames, nil +} + func (OpenDoorSecurityPolicyEnforcer) WithMetadataRollback(fn func() error) error { return fn() } @@ -518,6 +530,10 @@ func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context return errors.New("registry changes are denied by policy") } +func (ClosedDoorSecurityPolicyEnforcer) EnforceLogProviderPolicy(context.Context, []string) ([]string, error) { + return nil, errors.New("log provider is denied by policy") +} + func (ClosedDoorSecurityPolicyEnforcer) WithMetadataRollback(fn func() error) error { return fn() } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index f041c246aa..14c1c0be7b 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -408,9 +408,9 @@ func (policy *regoEnforcer) denyWithError(ctx context.Context, policyError error } func (policy *regoEnforcer) denyWithReason(ctx context.Context, enforcementPoint string, input inputData) error { + input["rule"] = enforcementPoint cleaned_input := policy.redactSensitiveData(input) cleaned_input = replaceCapabilitiesWithPlaceholders(cleaned_input) - input["rule"] = enforcementPoint policyDecision := map[string]interface{}{ "input": cleaned_input, "decision": "deny", @@ -626,6 +626,38 @@ func getEnvsToKeep(envList []string, results rpi.RegoQueryResult) ([]string, err return keepSet.toArray(), nil } +// getProvidersToKeep extracts the "providers_to_keep" field from the rego +// query results for log_provider enforcement and intersects it (defensively) +// with the originally requested providerNames. Mirrors getEnvsToKeep. +// +// When the policy is older / does not return providers_to_keep, the entire +// requested list is kept (analogous to env_list). +func getProvidersToKeep(providerNames []string, results rpi.RegoQueryResult) ([]string, error) { + value, err := results.Value("providers_to_keep") + if err != nil || value == nil { + // policy did not return 'providers_to_keep'. Interpret as + // "proceed with provided providers". + return providerNames, nil + } + + providersAsInterfaces, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("policy returned incorrect type for 'providers_to_keep', expected []interface{}, received %T", value) + } + + keepSet := make(stringSet) + for _, providerAsInterface := range providersAsInterfaces { + if provider, ok := providerAsInterface.(string); ok { + keepSet.add(provider) + } else { + return nil, fmt.Errorf("members of providers_to_keep from policy must be strings, received %T", providerAsInterface) + } + } + + keepSet = keepSet.intersect(toStringSet(providerNames)) + return keepSet.toArray(), nil +} + func getCapsToKeep(capsList *oci.LinuxCapabilities, results rpi.RegoQueryResult) (*oci.LinuxCapabilities, error) { value, err := results.Value("caps_list") if err != nil || value == nil { @@ -1517,6 +1549,17 @@ func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, co return err } +func (policy *regoEnforcer) EnforceLogProviderPolicy(ctx context.Context, providerNames []string) ([]string, error) { + input := inputData{ + "providers": providerNames, + } + results, err := policy.enforce(ctx, "log_provider", input) + if err != nil { + return nil, err + } + return getProvidersToKeep(providerNames, results) +} + func (policy *regoEnforcer) GetUserInfo(process *oci.Process, rootPath string) (IDName, []IDName, string, error) { return GetAllUserInfo(process, rootPath) } diff --git a/test/pkg/securitypolicy/policy.go b/test/pkg/securitypolicy/policy.go index eeb93f67c8..bdf748bac6 100644 --- a/test/pkg/securitypolicy/policy.go +++ b/test/pkg/securitypolicy/policy.go @@ -64,6 +64,7 @@ func PolicyWithOpts(tb testing.TB, policyType string, pOpts ...securitypolicy.Po config.AllowEnvironmentVariableDropping, config.AllowUnencryptedScratch, config.AllowCapabilityDropping, + config.AllowLogProviderDropping, ) if err != nil { tb.Fatal(err)